Files
B.Tech-Project-III/thirdeye/dashboard/app/knowledge-base/KnowledgeBrowser.tsx
2026-04-05 00:43:23 +05:30

549 lines
22 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import {
fetchGroups,
fetchKnowledgeBrowse,
Group,
Signal,
KnowledgeBrowseResponse,
KnowledgeDayEntry,
KnowledgeTopicSummary,
parseMetaList,
formatRelativeTime,
} from "../lib/api";
// ─── Helpers ──────────────────────────────────────────────────────────────────
const LENS_COLOR: Record<string, string> = {
dev: "#00daf3",
product: "#A78BFA",
client: "#fbbf24",
community: "#34d399",
meet: "#f87171",
jira: "#fb923c",
};
const SEVERITY_STYLE: Record<string, { color: string; bg: string }> = {
critical: { color: "#ff6f78", bg: "rgba(255,111,120,0.12)" },
high: { color: "#ffb300", bg: "rgba(255,179,0,0.12)" },
medium: { color: "#A78BFA", bg: "rgba(167,139,250,0.12)" },
low: { color: "#6B7280", bg: "rgba(107,114,128,0.10)" },
};
const TYPE_LABEL: Record<string, string> = {
architecture_decision: "Architecture",
tech_debt: "Tech Debt",
knowledge_silo_evidence: "Knowledge Silo",
recurring_bug: "Bug",
stack_decision: "Stack",
deployment_risk: "Deploy Risk",
workaround: "Workaround",
delivery_commitment: "Commitment",
feature_request: "Feature",
user_pain_point: "Pain Point",
roadmap_drift: "Roadmap",
priority_conflict: "Conflict",
metric_mention: "Metric",
user_quote: "Quote",
competitor_intel: "Competitor",
promise: "Promise",
scope_creep: "Scope Creep",
sentiment_signal: "Sentiment",
unanswered_request: "Unanswered",
satisfaction: "Satisfaction",
escalation_risk: "Escalation",
client_decision: "Client Decision",
meet_decision: "Decision",
meet_action_item: "Action Item",
meet_blocker: "Blocker",
meet_risk: "Risk",
meet_summary: "Summary",
};
function typeLabel(t: string) {
return TYPE_LABEL[t] ?? t.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
function fmtDate(iso: string): string {
if (!iso || iso === "unknown") return "Unknown";
const d = new Date(iso + (iso.includes("T") ? "" : "T00:00:00"));
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" };
if (d.toDateString() === today.toDateString()) return "Today · " + d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
if (d.toDateString() === yesterday.toDateString()) return "Yesterday · " + d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
return d.toLocaleDateString("en-US", opts);
}
function fmtTime(iso: string): string {
if (!iso) return "";
try {
return new Date(iso).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
} catch {
return "";
}
}
// ─── Signal Card ─────────────────────────────────────────────────────────────
function SignalCard({ signal, expanded, onToggle }: {
signal: Signal;
expanded: boolean;
onToggle: () => void;
}) {
const meta = signal.metadata;
const lens = meta.lens || "dev";
const lensColor = LENS_COLOR[lens] ?? "#A78BFA";
const sev = SEVERITY_STYLE[meta.severity] ?? SEVERITY_STYLE.low;
const entities = parseMetaList(meta.entities).slice(0, 3);
const keywords = parseMetaList(meta.keywords).slice(0, 3);
const summary = meta.summary || signal.document || "";
return (
<button
onClick={onToggle}
className="w-full text-left rounded-xl border transition-all duration-200 group"
style={{
backgroundColor: expanded ? "rgba(167,139,250,0.06)" : "rgba(11,8,18,0.7)",
borderColor: expanded ? "rgba(167,139,250,0.25)" : "rgba(167,139,250,0.08)",
}}
>
{/* Top row */}
<div className="flex items-center justify-between px-3 pt-2.5 pb-1 gap-2">
<span
className="text-[9px] font-mono font-bold px-2 py-0.5 rounded-md tracking-wider uppercase shrink-0"
style={{ color: lensColor, backgroundColor: `${lensColor}18`, border: `1px solid ${lensColor}28` }}
>
{typeLabel(meta.type)}
</span>
<div className="flex items-center gap-1.5 shrink-0">
<span
className="text-[8px] font-mono font-semibold px-1.5 py-0.5 rounded-md uppercase tracking-wider"
style={{ color: sev.color, backgroundColor: sev.bg }}
>
{meta.severity}
</span>
<span className="text-[8px] text-[#6B7280] font-mono">{fmtTime(meta.timestamp)}</span>
</div>
</div>
{/* Summary */}
<p
className="px-3 pb-2 text-[11px] text-[#C4B5F4] leading-relaxed font-medium"
style={{ display: "-webkit-box", WebkitLineClamp: expanded ? 999 : 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}
>
{summary}
</p>
{/* Entities + raw quote on expand */}
{expanded && (
<div className="px-3 pb-2.5 space-y-2">
{meta.raw_quote && (
<p className="text-[10px] text-[#8B7BB1] italic border-l-2 pl-2 leading-relaxed" style={{ borderColor: lensColor + "60" }}>
"{meta.raw_quote}"
</p>
)}
<div className="flex flex-wrap gap-1.5">
{entities.map((e, i) => (
<span key={i} className="text-[9px] font-mono text-[#A78BFA] bg-[#2D1B4E]/50 px-1.5 py-0.5 rounded border border-[#A78BFA]/15">
{e}
</span>
))}
{keywords.map((k, i) => (
<span key={i} className="text-[9px] font-mono text-[#6B7280] bg-[#1A1A2E]/50 px-1.5 py-0.5 rounded border border-white/5">
#{k}
</span>
))}
</div>
</div>
)}
{/* Entities preview (collapsed) */}
{!expanded && entities.length > 0 && (
<div className="px-3 pb-2.5 flex flex-wrap gap-1">
{entities.map((e, i) => (
<span key={i} className="text-[9px] font-mono text-[#8B7BB1]">{e}</span>
))}
</div>
)}
</button>
);
}
// ─── Day Section ─────────────────────────────────────────────────────────────
function DaySection({ entry, activeTopic }: { entry: KnowledgeDayEntry; activeTopic: string | null }) {
const [expandedId, setExpandedId] = useState<string | null>(null);
const visibleSignals = activeTopic
? entry.signals.filter((s) => {
try {
const kws: string[] = JSON.parse(s.metadata.keywords || "[]");
return kws.map((k) => k.toLowerCase().trim()).includes(activeTopic.toLowerCase()) ||
s.metadata.type.replace(/_/g, " ").toLowerCase() === activeTopic.toLowerCase();
} catch {
return false;
}
})
: entry.signals;
if (visibleSignals.length === 0) return null;
return (
<div className="mb-4">
{/* Day header */}
<div className="flex items-center gap-2 mb-2 sticky top-0 z-10 py-1"
style={{ background: "linear-gradient(to bottom, #09090B, transparent)" }}>
<div className="h-px w-2 bg-[#A78BFA]/30" />
<span className="text-[9px] font-mono font-bold text-[#A78BFA]/70 uppercase tracking-[0.2em] shrink-0">
{fmtDate(entry.date)}
</span>
<div className="flex-1 h-px bg-[#A78BFA]/10" />
<span className="text-[8px] font-mono text-[#6B7280] shrink-0">{visibleSignals.length} signals</span>
</div>
{/* Topic chips for this day */}
{entry.topics.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2 px-0.5">
{entry.topics.slice(0, 5).map((t, i) => (
<span key={i} className="text-[8px] font-mono text-[#6B7280] bg-[#1A1A2E]/60 px-1.5 py-0.5 rounded-md border border-white/5">
{t}
</span>
))}
</div>
)}
{/* Signal cards */}
<div className="space-y-1.5">
{visibleSignals.map((sig) => (
<SignalCard
key={sig.id}
signal={sig}
expanded={expandedId === sig.id}
onToggle={() => setExpandedId(expandedId === sig.id ? null : sig.id)}
/>
))}
</div>
</div>
);
}
// ─── Topic Chips ─────────────────────────────────────────────────────────────
function TopicChips({
topics,
activeTopic,
onSelect,
}: {
topics: KnowledgeTopicSummary[];
activeTopic: string | null;
onSelect: (t: string | null) => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
return (
<div ref={scrollRef} className="flex gap-1.5 overflow-x-auto pb-1" style={{ scrollbarWidth: "none" }}>
<button
onClick={() => onSelect(null)}
className="shrink-0 text-[9px] font-mono font-bold px-2.5 py-1 rounded-lg border transition-all uppercase tracking-wider"
style={{
color: activeTopic === null ? "#09090B" : "#A78BFA",
backgroundColor: activeTopic === null ? "#A78BFA" : "rgba(167,139,250,0.1)",
borderColor: activeTopic === null ? "#A78BFA" : "rgba(167,139,250,0.25)",
}}
>
All
</button>
{topics.map((t) => (
<button
key={t.name}
onClick={() => onSelect(activeTopic === t.name ? null : t.name)}
className="shrink-0 flex items-center gap-1 text-[9px] font-mono px-2 py-1 rounded-lg border transition-all"
style={{
color: activeTopic === t.name ? "#09090B" : "#8B7BB1",
backgroundColor: activeTopic === t.name ? "#A78BFA" : "rgba(167,139,250,0.06)",
borderColor: activeTopic === t.name ? "#A78BFA" : "rgba(167,139,250,0.15)",
}}
>
<span className="truncate max-w-[80px]">{t.name}</span>
<span
className="shrink-0 text-[8px] font-bold rounded-full w-4 h-4 flex items-center justify-center"
style={{
color: activeTopic === t.name ? "#09090B" : "#A78BFA",
backgroundColor: activeTopic === t.name ? "rgba(0,0,0,0.2)" : "rgba(167,139,250,0.15)",
}}
>
{t.signal_count > 99 ? "99+" : t.signal_count}
</span>
</button>
))}
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function KnowledgeBrowser() {
const [groups, setGroups] = useState<Group[]>([]);
const [selectedGroup, setSelectedGroup] = useState<string>("");
const [browseData, setBrowseData] = useState<KnowledgeBrowseResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTopic, setActiveTopic] = useState<string | null>(null);
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const timelineRef = useRef<HTMLDivElement>(null);
// Load groups
useEffect(() => {
fetchGroups().then((grps) => {
setGroups(grps);
if (grps.length > 0) setSelectedGroup(grps[0].group_id);
}).catch(() => {});
}, []);
// Load browse data when group or date filters change
const loadBrowse = useCallback(async (groupId: string, from: string, to: string) => {
if (!groupId) return;
setLoading(true);
setError(null);
setActiveTopic(null);
try {
const data = await fetchKnowledgeBrowse(groupId, {
dateFrom: from || undefined,
dateTo: to || undefined,
});
setBrowseData(data);
} catch {
setError("Backend unavailable — start the ThirdEye server to browse knowledge.");
setBrowseData(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (selectedGroup) loadBrowse(selectedGroup, dateFrom, dateTo);
}, [selectedGroup, loadBrowse]);
const handleApplyDates = () => {
if (selectedGroup) loadBrowse(selectedGroup, dateFrom, dateTo);
};
const handleClearDates = () => {
setDateFrom("");
setDateTo("");
if (selectedGroup) loadBrowse(selectedGroup, "", "");
};
const selectedGroupData = groups.find((g) => g.group_id === selectedGroup);
// Count visible signals for active topic across all days
const visibleCount = browseData
? activeTopic
? browseData.timeline.reduce((acc, day) => {
const cnt = day.signals.filter((s) => {
try {
const kws: string[] = JSON.parse(s.metadata.keywords || "[]");
return kws.map((k) => k.toLowerCase().trim()).includes(activeTopic.toLowerCase()) ||
s.metadata.type.replace(/_/g, " ").toLowerCase() === activeTopic.toLowerCase();
} catch { return false; }
}).length;
return acc + cnt;
}, 0)
: browseData.total_signals
: 0;
return (
<div className="w-full flex flex-col gap-3 animate-fade-in-right">
{/* ── Group Selector ──────────────────────────────────────────────── */}
<div className="px-4 py-3 rounded-[1.25rem] bg-[#110D1A]/90 backdrop-blur-2xl border border-[#A78BFA]/10 shadow-[0_8px_32px_rgba(0,0,0,0.5)]">
<p className="text-[9px] font-mono text-[#A78BFA]/60 uppercase tracking-widest mb-1.5">
Knowledge Source
</p>
{groups.length === 0 ? (
<p className="text-[11px] text-[#6B7280] font-mono">Loading groups</p>
) : (
<select
value={selectedGroup}
onChange={(e) => setSelectedGroup(e.target.value)}
className="w-full bg-transparent text-[#E9D9FF] text-[12px] font-semibold focus:outline-none cursor-pointer"
style={{ appearance: "none" }}
>
{groups.map((g) => (
<option key={g.group_id} value={g.group_id} style={{ backgroundColor: "#110D1A" }}>
{g.group_name} {g.signal_count} signals
</option>
))}
</select>
)}
{selectedGroupData && (
<p className="text-[9px] font-mono text-[#6B7280] mt-1 uppercase tracking-wider">
lens: {selectedGroupData.lens || "unknown"}
</p>
)}
</div>
{/* ── Date Range Filter ───────────────────────────────────────────── */}
<div className="px-4 py-3 rounded-[1.25rem] bg-[#110D1A]/90 backdrop-blur-2xl border border-[#A78BFA]/10 shadow-[0_8px_32px_rgba(0,0,0,0.5)]">
<p className="text-[9px] font-mono text-[#A78BFA]/60 uppercase tracking-widest mb-2">
Date Range
</p>
<div className="flex items-center gap-2">
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="flex-1 bg-[#0C0814]/70 border border-[#A78BFA]/15 rounded-lg text-[10px] font-mono text-[#C4B5F4] px-2 py-1.5 focus:outline-none focus:border-[#A78BFA]/40"
style={{ colorScheme: "dark" }}
/>
<span className="text-[9px] text-[#6B7280] font-mono shrink-0"></span>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="flex-1 bg-[#0C0814]/70 border border-[#A78BFA]/15 rounded-lg text-[10px] font-mono text-[#C4B5F4] px-2 py-1.5 focus:outline-none focus:border-[#A78BFA]/40"
style={{ colorScheme: "dark" }}
/>
<button
onClick={handleApplyDates}
className="shrink-0 material-symbols-outlined text-[18px] text-[#A78BFA]/60 hover:text-[#A78BFA] transition-colors"
title="Apply date filter"
>
filter_alt
</button>
{(dateFrom || dateTo) && (
<button
onClick={handleClearDates}
className="shrink-0 material-symbols-outlined text-[18px] text-[#6B7280] hover:text-[#ff6f78] transition-colors"
title="Clear filter"
>
close
</button>
)}
</div>
</div>
{/* ── Browser Panel ────────────────────────────────────────────────── */}
<div className="rounded-[1.25rem] flex flex-col shadow-[0_16px_48px_rgba(0,0,0,0.6)] bg-[#110D1A]/95 backdrop-blur-2xl border border-[#A78BFA]/10 overflow-hidden">
{/* Header */}
<div className="px-4 pt-4 pb-3 bg-[#1A132B]/30 border-b border-[#A78BFA]/8">
<div className="flex items-center justify-between mb-0.5">
<span className="text-[9px] font-mono bg-[#2D1B4E]/80 text-[#B79FFF] px-2.5 py-1 rounded-md tracking-[0.15em] border border-[#A78BFA]/20 uppercase">
Knowledge Browser
</span>
{loading && (
<span className="material-symbols-outlined animate-spin text-[16px] text-[#A78BFA]/50">autorenew</span>
)}
</div>
{browseData && !loading && (
<p className="text-[10px] text-[#8B7BB1] font-mono mt-1.5">
<span className="text-[#00daf3] font-bold">{visibleCount}</span>
{activeTopic ? ` signals · topic: "${activeTopic}"` : ` signals · ${browseData.topics.length} topics`}
</p>
)}
</div>
{/* Topic Chips */}
{browseData && browseData.topics.length > 0 && (
<div className="px-3 py-2.5 border-b border-[#A78BFA]/8 bg-[#0C0814]/30">
<p className="text-[8px] font-mono text-[#6B7280] uppercase tracking-widest mb-1.5">Topics</p>
<TopicChips
topics={browseData.topics}
activeTopic={activeTopic}
onSelect={setActiveTopic}
/>
</div>
)}
{/* Timeline */}
<div
ref={timelineRef}
className="flex-1 overflow-y-auto px-3 py-3"
style={{ maxHeight: "600px", scrollbarWidth: "none" }}
>
{/* Loading state */}
{loading && (
<div className="flex flex-col items-center justify-center py-10 gap-3">
<span className="material-symbols-outlined animate-spin text-[32px] text-[#A78BFA]/40">autorenew</span>
<p className="text-[10px] font-mono text-[#6B7280] uppercase tracking-widest">Loading knowledge</p>
</div>
)}
{/* Error state */}
{!loading && error && (
<div className="flex flex-col items-center justify-center py-8 gap-2 text-center">
<span className="material-symbols-outlined text-[28px] text-[#ff6f78]/50">cloud_off</span>
<p className="text-[10px] font-mono text-[#6B7280] leading-relaxed">{error}</p>
</div>
)}
{/* Empty state */}
{!loading && !error && browseData && browseData.timeline.length === 0 && (
<div className="flex flex-col items-center justify-center py-10 gap-2 text-center">
<span className="material-symbols-outlined text-[32px] text-[#A78BFA]/30">library_books</span>
<p className="text-[11px] font-mono text-[#6B7280]">No signals found</p>
<p className="text-[9px] font-mono text-[#4B4860]">
{dateFrom || dateTo ? "Try adjusting the date range" : "Send messages to populate this group"}
</p>
</div>
)}
{/* Timeline days */}
{!loading && !error && browseData && browseData.timeline.map((entry) => (
<DaySection key={entry.date} entry={entry} activeTopic={activeTopic} />
))}
{/* No signals for active topic */}
{!loading && !error && browseData && activeTopic && visibleCount === 0 && (
<div className="flex flex-col items-center justify-center py-8 gap-2 text-center">
<span className="material-symbols-outlined text-[28px] text-[#A78BFA]/30">search_off</span>
<p className="text-[10px] font-mono text-[#6B7280]">No signals for topic "{activeTopic}"</p>
<button
onClick={() => setActiveTopic(null)}
className="text-[9px] font-mono text-[#A78BFA] hover:text-white transition-colors underline underline-offset-2"
>
Clear filter
</button>
</div>
)}
</div>
{/* Footer stats */}
{browseData && !loading && (
<div className="px-4 py-2.5 border-t border-[#A78BFA]/8 bg-[#0C0814]/40">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="text-center">
<p className="text-[14px] font-mono font-bold text-[#00daf3]">{browseData.total_signals}</p>
<p className="text-[7px] font-mono text-[#6B7280] uppercase tracking-wider">Signals</p>
</div>
<div className="w-px h-6 bg-[#A78BFA]/10" />
<div className="text-center">
<p className="text-[14px] font-mono font-bold text-[#A78BFA]">{browseData.topics.length}</p>
<p className="text-[7px] font-mono text-[#6B7280] uppercase tracking-wider">Topics</p>
</div>
<div className="w-px h-6 bg-[#A78BFA]/10" />
<div className="text-center">
<p className="text-[14px] font-mono font-bold text-[#B79FFF]">{browseData.timeline.length}</p>
<p className="text-[7px] font-mono text-[#6B7280] uppercase tracking-wider">Days</p>
</div>
</div>
<button
onClick={() => loadBrowse(selectedGroup, dateFrom, dateTo)}
className="material-symbols-outlined text-[18px] text-[#A78BFA]/40 hover:text-[#A78BFA] transition-colors"
title="Refresh"
>
refresh
</button>
</div>
</div>
)}
</div>
</div>
);
}