diff --git a/api/routers/chatbot.py b/api/routers/chatbot.py index 2da8aa5..6897fa6 100644 --- a/api/routers/chatbot.py +++ b/api/routers/chatbot.py @@ -1,7 +1,9 @@ import ollama import chromadb from pypdf import PdfReader -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel import uvicorn client = chromadb.Client() @@ -22,11 +24,24 @@ print("Embeddings done!") app = FastAPI() +# Allow browser calls from the frontend +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +class ChatRequest(BaseModel): + prompt: str + @app.post("/chat") -async def chat_bot(prompt: str): - if not prompt: - return - query = prompt +async def chat_bot(prompt: str | None = None, body: ChatRequest | None = None): + # Accept prompt from either query (?prompt=) or JSON body {"prompt": "..."} + query = prompt or (body.prompt if body else None) + if not query: + raise HTTPException(status_code=400, detail="Missing prompt") response = ollama.embed(model="nomic-embed-text", input=query) query_embedding = response["embeddings"][0] diff --git a/frontend/app/api/chat/route.ts b/frontend/app/api/chat/route.ts new file mode 100644 index 0000000..13fa318 --- /dev/null +++ b/frontend/app/api/chat/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server'; + +const CHAT_HOST = process.env.CHAT_API_URL || process.env.NEXT_PUBLIC_CHAT_API_URL || 'https://f52c8f4e7dfc.ngrok-free.app'; + +export async function POST(req: Request) { + try { + const body = await req.json().catch(() => ({})); + const prompt = typeof body?.prompt === 'string' ? body.prompt : ''; + if (!prompt.trim()) { + return NextResponse.json({ detail: 'Missing prompt' }, { status: 400 }); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 120_000); + try { + const upstream = await fetch(`${CHAT_HOST}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ prompt }), + signal: controller.signal, + }); + + const text = await upstream.text(); + let json: any; + try { json = JSON.parse(text); } catch { json = { response: text }; } + + if (!upstream.ok) { + return NextResponse.json(json || { detail: 'Chat failed' }, { status: upstream.status }); + } + return NextResponse.json(json); + } finally { + clearTimeout(timeout); + } + } catch (err: any) { + const msg = err?.name === 'AbortError' ? 'Request timed out – model may be overloaded.' : (err?.message || 'Unexpected error'); + return NextResponse.json({ detail: msg }, { status: 500 }); + } +} diff --git a/frontend/components/try/ChatbotPanel.tsx b/frontend/components/try/ChatbotPanel.tsx index ae7d9dc..ebff748 100644 --- a/frontend/components/try/ChatbotPanel.tsx +++ b/frontend/components/try/ChatbotPanel.tsx @@ -1,41 +1,118 @@ "use client"; -import { useState } from "react"; +import { useState, useRef, useEffect } from "react"; +import { chatWithCopilot } from "../../lib/api"; + +const CHAT_ENDPOINT = process.env.NEXT_PUBLIC_CHAT_API_URL || 'https://f52c8f4e7dfc.ngrok-free.app'; export function ChatbotPanel() { - const [messages] = useState<{ role: "user" | "assistant"; content: string }[]>([ - { role: "assistant", content: "Hi! I'll help you interpret compliance results soon." }, + const [messages, setMessages] = useState<{ role: "user" | "assistant"; content: string; pending?: boolean; error?: boolean }[]>([ + { role: "assistant", content: "Hi! I'm your Privacy Copilot. Ask me about compliance, GDPR articles, or dataset risks." }, ]); + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [delayedError, setDelayedError] = useState(null); + const scrollRef = useRef(null); - return ( -
+ useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const prompt = input.trim(); + if (!prompt || isLoading) return; + setInput(""); + setDelayedError(null); + setMessages(prev => [...prev, { role: "user", content: prompt }, { role: "assistant", content: "Thinking…", pending: true }]); + setIsLoading(true); + + // Delay window for showing errors (avoid instant flashing if slow model) + const errorDisplayDelayMs = 4_000; + let canShowError = false; + const delayTimer = setTimeout(() => { canShowError = true; if (delayedError) showErrorBubble(delayedError); }, errorDisplayDelayMs); + + function showErrorBubble(msg: string) { + setMessages(prev => prev.map(m => m.pending ? { ...m, content: msg, pending: false, error: true } : m)); + } + + try { + let responseText: string | null = null; + // Primary attempt via shared client + try { + responseText = await chatWithCopilot(prompt); + } catch (primaryErr: any) { + // Fallback: replicate working curl (query param, empty body) + try { + const res = await fetch(`${CHAT_ENDPOINT}/chat?prompt=${encodeURIComponent(prompt)}` , { + method: 'POST', + headers: { 'accept': 'application/json' }, + body: '' + }); + if (res.ok) { + const j = await res.json(); + responseText = j.response || JSON.stringify(j); + } else { + throw primaryErr; + } + } catch { throw primaryErr; } + } + clearTimeout(delayTimer); + setMessages(prev => prev.map(m => m.pending ? { ...m, content: responseText || 'No response text', pending: false } : m)); + } catch (err: any) { + clearTimeout(delayTimer); + const errMsg = err?.message || 'Unexpected error'; + if (canShowError) { + showErrorBubble(errMsg); + } else { + setDelayedError(errMsg); + } + } finally { + setIsLoading(false); + } + } + + return ( +

Privacy Copilot

-
+
{messages.map((m, i) => (
{m.content}
))}
-
e.preventDefault()}> + setInput(e.target.value)} + placeholder="Ask about GDPR, compliance, privacy risks..." + className="flex-1 rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 disabled:opacity-60" + disabled={isLoading} />
+

Responses may take up to 1–2 minutes while the local model generates output.

); diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index aeb3059..5abdade 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -4,6 +4,8 @@ */ const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; +// Base URL for chat backend (do not include path) +const CHAT_API_BASE_URL = process.env.NEXT_PUBLIC_CHAT_API_URL || 'https://f52c8f4e7dfc.ngrok-free.app'; export interface AnalyzeResponse { status: string; @@ -188,3 +190,42 @@ export async function healthCheck() { const response = await fetch(`${API_BASE_URL}/health`); return response.json(); } + +/** + * Chat with Privacy Copilot (ngrok tunneled backend) + * Provides a resilient call with extended timeout and delayed error surfacing. + */ +export async function chatWithCopilot(prompt: string): Promise { + if (!prompt.trim()) throw new Error('Empty prompt'); + + const controller = new AbortController(); + const timeoutMs = 120_000; // allow up to 2 minutes for slow local model + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + // EXACT same request style as the provided curl: + // curl -X POST 'https:///chat?prompt=hello' -H 'accept: application/json' -d '' + const url = `${CHAT_API_BASE_URL}/chat?prompt=${encodeURIComponent(prompt)}`; + const res = await fetch(url, { + method: 'POST', + headers: { 'accept': 'application/json' }, + body: '', // empty body + signal: controller.signal, + }); + if (!res.ok) { + let detail: any = undefined; + try { detail = await res.json(); } catch {} + throw new Error(detail?.detail || `Chat failed (${res.status})`); + } + + const json = await res.json(); + return json.response || JSON.stringify(json); + } catch (err: any) { + if (err?.name === 'AbortError') { + throw new Error('Request timed out – model may be overloaded.'); + } + throw err; + } finally { + clearTimeout(timeout); + } +} diff --git a/frontend/nordic-privacy-ai/components/try/CenterPanel.tsx b/frontend/nordic-privacy-ai/components/try/CenterPanel.tsx deleted file mode 100644 index 1e44eb7..0000000 --- a/frontend/nordic-privacy-ai/components/try/CenterPanel.tsx +++ /dev/null @@ -1,620 +0,0 @@ -"use client"; -import { TryTab } from "./Sidebar"; -import { useState, useRef, useCallback, useEffect } from "react"; -import { saveLatestUpload, getLatestUpload, deleteLatestUpload } from "../../lib/indexeddb"; -import { analyzeDataset, cleanDataset, getReportUrl, type AnalyzeResponse, type CleanResponse } from "../../lib/api"; - -interface CenterPanelProps { - tab: TryTab; - onAnalyze?: () => void; -} - -interface UploadedFileMeta { - name: string; - size: number; - type: string; - contentPreview: string; -} - -interface TablePreviewData { - headers: string[]; - rows: string[][]; - origin: 'csv'; -} - -export function CenterPanel({ tab, onAnalyze }: CenterPanelProps) { - const PREVIEW_BYTES = 64 * 1024; // read first 64KB slice for large-file preview - const [fileMeta, setFileMeta] = useState(null); - const [uploadedFile, setUploadedFile] = useState(null); - const [isDragging, setIsDragging] = useState(false); - const [progress, setProgress] = useState(0); - const [progressLabel, setProgressLabel] = useState("Processing"); - const [tablePreview, setTablePreview] = useState(null); - const inputRef = useRef(null); - const [loadedFromCache, setLoadedFromCache] = useState(false); - const [isProcessing, setIsProcessing] = useState(false); - const [error, setError] = useState(null); - - // Analysis results - const [analyzeResult, setAnalyzeResult] = useState(null); - const [cleanResult, setCleanResult] = useState(null); - - const reset = () => { - setFileMeta(null); - setUploadedFile(null); - setProgress(0); - setProgressLabel("Processing"); - setTablePreview(null); - setError(null); - }; - - // Handle API calls - const handleAnalyze = async () => { - if (!uploadedFile) { - setError("No file uploaded"); - return; - } - - setIsProcessing(true); - setError(null); - setProgressLabel("Analyzing dataset..."); - - try { - const result = await analyzeDataset(uploadedFile); - setAnalyzeResult(result); - setProgressLabel("Analysis complete!"); - onAnalyze?.(); // Navigate to bias-analysis tab - } catch (err: any) { - setError(err.message || "Analysis failed"); - } finally { - setIsProcessing(false); - } - }; - - const handleClean = async () => { - if (!uploadedFile) { - setError("No file uploaded"); - return; - } - - setIsProcessing(true); - setError(null); - setProgressLabel("Cleaning dataset..."); - - try { - const result = await cleanDataset(uploadedFile); - setCleanResult(result); - setProgressLabel("Cleaning complete!"); - } catch (err: any) { - setError(err.message || "Cleaning failed"); - } finally { - setIsProcessing(false); - } - }; function tryParseCSV(text: string, maxRows = 50, maxCols = 40): TablePreviewData | null { - const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0); - if (lines.length < 2) return null; - const commaDensity = lines.slice(0, 10).filter(l => l.includes(',')).length; - if (commaDensity < 2) return null; - const parseLine = (line: string) => { - const out: string[] = []; - let cur = ''; - let inQuotes = false; - for (let i = 0; i < line.length; i++) { - const ch = line[i]; - if (ch === '"') { - if (inQuotes && line[i + 1] === '"') { cur += '"'; i++; } else { inQuotes = !inQuotes; } - } else if (ch === ',' && !inQuotes) { - out.push(cur); - cur = ''; - } else { cur += ch; } - } - out.push(cur); - return out.map(c => c.trim()); - }; - const raw = lines.slice(0, maxRows).map(parseLine); - if (raw.length === 0) return null; - const headers = raw[0]; - const colCount = Math.min(headers.length, maxCols); - const rows = raw.slice(1).map(r => r.slice(0, colCount)); - return { headers: headers.slice(0, colCount), rows, origin: 'csv' }; - } - - // We no longer build table preview for JSON; revert JSON to raw text view. - - const processFile = useCallback(async (f: File) => { - if (!f) return; - const isCSV = /\.csv$/i.test(f.name); - setProgress(0); - setUploadedFile(f); // Save the file for API calls - - // For large files, show a progress bar while reading the file stream (no preview) - if (f.size > 1024 * 1024) { - setProgressLabel("Uploading"); - const metaObj: UploadedFileMeta = { - name: f.name, - size: f.size, - type: f.type || "unknown", - contentPreview: `Loading partial preview (first ${Math.round(PREVIEW_BYTES/1024)}KB)...`, - }; - setFileMeta(metaObj); - setTablePreview(null); - // Save to IndexedDB immediately so it persists without needing full read - (async () => { - try { await saveLatestUpload(f, metaObj); } catch {} - })(); - // Read head slice for partial preview & possible CSV table extraction - try { - const headBlob = f.slice(0, PREVIEW_BYTES); - const headReader = new FileReader(); - headReader.onload = async () => { - try { - const buf = headReader.result as ArrayBuffer; - const decoder = new TextDecoder(); - const text = decoder.decode(buf); - setFileMeta(prev => prev ? { ...prev, contentPreview: text.slice(0, 4000) } : prev); - if (isCSV) { - const parsed = tryParseCSV(text); - setTablePreview(parsed); - } else { - setTablePreview(null); - } - try { await saveLatestUpload(f, { ...metaObj, contentPreview: text.slice(0, 4000) }); } catch {} - } catch { /* ignore */ } - }; - headReader.readAsArrayBuffer(headBlob); - } catch { /* ignore */ } - // Use streaming read for progress without buffering entire file in memory - try { - const stream: ReadableStream | undefined = (typeof (f as any).stream === "function" ? (f as any).stream() : undefined); - if (stream && typeof stream.getReader === "function") { - const reader = stream.getReader(); - let loaded = 0; - const total = f.size || 1; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - loaded += value ? value.length : 0; - const pct = Math.min(100, Math.round((loaded / total) * 100)); - setProgress(pct); - } - setProgress(100); - } else { - // Fallback to FileReader progress events - const reader = new FileReader(); - reader.onprogress = (evt) => { - if (evt.lengthComputable) { - const pct = Math.min(100, Math.round((evt.loaded / evt.total) * 100)); - setProgress(pct); - } else { - setProgress((p) => (p < 90 ? p + 5 : p)); - } - }; - reader.onloadend = () => setProgress(100); - reader.onerror = () => setProgress(0); - reader.readAsArrayBuffer(f); - } - } catch { - setProgress(100); - } - return; - } - const reader = new FileReader(); - reader.onprogress = (evt) => { - if (evt.lengthComputable) { - const pct = Math.min(100, Math.round((evt.loaded / evt.total) * 100)); - setProgress(pct); - } else { - setProgress((p) => (p < 90 ? p + 5 : p)); - } - }; - reader.onload = async () => { - try { - const buf = reader.result as ArrayBuffer; - const decoder = new TextDecoder(); - const text = decoder.decode(buf); - const metaObj: UploadedFileMeta = { - name: f.name, - size: f.size, - type: f.type || "unknown", - contentPreview: text.slice(0, 4000), - }; - setFileMeta(metaObj); - if (isCSV) { - const parsed = tryParseCSV(text); - setTablePreview(parsed); - } else { - setTablePreview(null); - } - // Save file blob and meta to browser cache (IndexedDB) - try { - await saveLatestUpload(f, metaObj); - } catch {} - setProgressLabel("Processing"); - setProgress(100); - } catch (e) { - const metaObj: UploadedFileMeta = { - name: f.name, - size: f.size, - type: f.type || "unknown", - contentPreview: "Unable to decode preview.", - }; - setFileMeta(metaObj); - setTablePreview(null); - try { - await saveLatestUpload(f, metaObj); - } catch {} - setProgressLabel("Processing"); - setProgress(100); - } - }; - reader.onerror = () => { - setProgress(0); - }; - reader.readAsArrayBuffer(f); - }, []); - - function handleFileChange(e: React.ChangeEvent) { - const f = e.target.files?.[0]; - processFile(f as File); - } - - const onDragOver = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(true); - }; - const onDragLeave = () => setIsDragging(false); - const onDrop = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - const f = e.dataTransfer.files?.[0]; - processFile(f as File); - }; - - // Load last cached upload on mount (processing tab only) - useEffect(() => { - let ignore = false; - if (tab !== "processing") return; - (async () => { - try { - const { file, meta } = await getLatestUpload(); - if (!ignore && meta) { - setFileMeta(meta as UploadedFileMeta); - if (file) { - setUploadedFile(file); - } - setLoadedFromCache(true); - } - } catch {} - })(); - return () => { - ignore = true; - }; - }, [tab]); function renderTabContent() { - switch (tab) { - case "processing": - return ( -
-

Upload & Process Data

-

Upload a CSV / JSON / text file. We will later parse, detect PII, and queue analyses.

-
-
-

Drag & drop a CSV / JSON / TXT here, or click to browse.

-
- -
-
- - {progress > 0 && ( -
-
-
-
-
{progressLabel} {progress}%
-
- )} - {fileMeta && ( -
-
-
{fileMeta.name}
-
{Math.round(fileMeta.size / 1024)} KB
-
- {loadedFromCache && ( -
Loaded from browser cache
- )} -
{fileMeta.type || "Unknown type"}
- {/* Table preview when structured data detected; otherwise show text */} - {tablePreview && tablePreview.origin === 'csv' ? ( -
- - - - {tablePreview.headers.map((h, idx) => ( - - ))} - - - - {tablePreview.rows.map((r, i) => ( - - {r.map((c, j) => ( - - ))} - - ))} - -
{h}
{c}
-
- ) : ( -
-														{fileMeta.contentPreview || "(no preview)"}
-													
- )} - - {error && ( -
- ❌ {error} -
- )} - - {analyzeResult && ( -
- ✅ Analysis complete! View results in tabs. - - Download Report - -
- )} - - {cleanResult && ( -
- ✅ Cleaning complete! {cleanResult.summary.total_cells_affected} cells anonymized. - -
- )} - -
- - - -
-
- )} -
-
- ); - case "bias-analysis": - return ( -
-

Bias Analysis

- {analyzeResult ? ( -
-
-
-
Overall Bias Score
-
{(analyzeResult.bias_metrics.overall_bias_score * 100).toFixed(1)}%
-
-
-
Violations Detected
-
{analyzeResult.bias_metrics.violations_detected.length}
-
-
- -
-

Model Performance

-
-
-
Accuracy
-
{(analyzeResult.model_performance.accuracy * 100).toFixed(1)}%
-
-
-
Precision
-
{(analyzeResult.model_performance.precision * 100).toFixed(1)}%
-
-
-
Recall
-
{(analyzeResult.model_performance.recall * 100).toFixed(1)}%
-
-
-
F1 Score
-
{(analyzeResult.model_performance.f1_score * 100).toFixed(1)}%
-
-
-
-
- ) : ( -

Upload and analyze a dataset to see bias metrics.

- )} -
- ); - case "risk-analysis": - return ( -
-

Risk Analysis

- {analyzeResult ? ( -
-
-
Overall Risk Score
-
{(analyzeResult.risk_assessment.overall_risk_score * 100).toFixed(1)}%
-
- - {cleanResult && ( -
-

PII Detection Results

-
-
Cells Anonymized: {cleanResult.summary.total_cells_affected}
-
Columns Removed: {cleanResult.summary.columns_removed.length}
-
Columns Anonymized: {cleanResult.summary.columns_anonymized.length}
-
-
- )} -
- ) : ( -

Upload and analyze a dataset to see risk assessment.

- )} -
- ); - case "bias-risk-mitigation": - return ( -
-

Mitigation Suggestions

- {analyzeResult && analyzeResult.recommendations.length > 0 ? ( -
- {analyzeResult.recommendations.map((rec, i) => ( -
- {rec} -
- ))} -
- ) : ( -

- Recommendations will appear here after analysis. -

- )} -
- ); - case "results": - return ( -
-

Results Summary

- {(analyzeResult || cleanResult) ? ( -
- {analyzeResult && ( -
-

Analysis Results

-
-
Dataset: {analyzeResult.filename}
-
Rows: {analyzeResult.dataset_info.rows}
-
Columns: {analyzeResult.dataset_info.columns}
-
Bias Score: {(analyzeResult.bias_metrics.overall_bias_score * 100).toFixed(1)}%
-
Risk Score: {(analyzeResult.risk_assessment.overall_risk_score * 100).toFixed(1)}%
-
- - Download Full Report → - -
- )} - - {cleanResult && ( -
-

Cleaning Results

-
-
Original: {cleanResult.dataset_info.original_rows} rows × {cleanResult.dataset_info.original_columns} cols
-
Cleaned: {cleanResult.dataset_info.cleaned_rows} rows × {cleanResult.dataset_info.cleaned_columns} cols
-
Cells Anonymized: {cleanResult.summary.total_cells_affected}
-
Columns Removed: {cleanResult.summary.columns_removed.length}
-
GDPR Compliant: {cleanResult.gdpr_compliance.length} articles applied
-
- -
- )} -
- ) : ( -

- Process a dataset to see aggregated results. -

- )} -
- ); - default: - return null; - } - } - - return ( -
- {renderTabContent()} -
- ); -} \ No newline at end of file