I mean, it's at a good place rn...

This commit is contained in:
Renn F
2026-04-20 15:10:54 +02:00
parent 0023c25d60
commit 8e201901c0
264 changed files with 36484 additions and 748 deletions
@@ -0,0 +1,24 @@
// Main component
export { KnowledgeBaseBrowser } from "./knowledge-base-browser";
// Shared components
export { KBIndexTypeBadge, getIndexTypeIcon, getIndexTypeLabel } from "./kb-index-type-badge";
export { KBStatsCard } from "./kb-stats-card";
export { KBSearchBar } from "./kb-search-bar";
export { KBFilters } from "./kb-filters";
// Search components
export { KBResultCard } from "./kb-result-card";
export { KBResultList } from "./kb-result-list";
// RAG components
export { RAGQueryInput } from "./rag-query-input";
export { RAGCitationCard } from "./rag-citation-card";
export { RAGAnswerDisplay } from "./rag-answer-display";
// Mentor components
export { MentorChat } from "./mentor-chat";
// Browse components
export { KBCategoryNav } from "./kb-category-nav";
export { KBCategoryView } from "./kb-category-view";
@@ -0,0 +1,123 @@
"use client";
import { KBIndexType, KBStats } from "@/types";
import { Code, FileText, MessageSquare, BookOpen, ChevronRight, AlertTriangle, Scale, GitBranch, ClipboardCheck, Lightbulb } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
const categoryConfig: Record<KBIndexType, { label: string; description: string; icon: React.ReactNode }> = {
[KBIndexType.CODE]: {
label: "Code",
description: "Source code, functions, and classes",
icon: <Code className="h-5 w-5 text-purple-500" />,
},
[KBIndexType.DOCUMENTATION]: {
label: "Documentation",
description: "READMEs, guides, and API docs",
icon: <FileText className="h-5 w-5 text-blue-500" />,
},
[KBIndexType.CONVERSATIONS]: {
label: "Conversations",
description: "Agent discussions and decisions",
icon: <MessageSquare className="h-5 w-5 text-green-500" />,
},
[KBIndexType.JOURNALS]: {
label: "Journals",
description: "Agent reflections and learnings",
icon: <BookOpen className="h-5 w-5 text-orange-500" />,
},
[KBIndexType.ERRORS]: {
label: "Errors",
description: "Error patterns and solutions",
icon: <AlertTriangle className="h-5 w-5 text-red-500" />,
},
[KBIndexType.STANDARDS]: {
label: "Standards",
description: "Coding, security, and workflow rules",
icon: <Scale className="h-5 w-5 text-cyan-500" />,
},
[KBIndexType.DECISIONS]: {
label: "Decisions",
description: "Architectural and design decisions",
icon: <GitBranch className="h-5 w-5 text-indigo-500" />,
},
[KBIndexType.REVIEWS]: {
label: "Reviews",
description: "Code review feedback",
icon: <ClipboardCheck className="h-5 w-5 text-pink-500" />,
},
[KBIndexType.LEARNINGS]: {
label: "Learnings",
description: "Cross-agent shared learnings",
icon: <Lightbulb className="h-5 w-5 text-yellow-500" />,
},
};
interface KBCategoryNavProps {
stats: KBStats | undefined;
isLoading: boolean;
selectedCategory: KBIndexType | null;
onSelectCategory: (category: KBIndexType) => void;
}
export function KBCategoryNav({
stats,
isLoading,
selectedCategory,
onSelectCategory,
}: KBCategoryNavProps) {
if (isLoading) {
return (
<div className="space-y-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-lg border">
<Skeleton className="h-10 w-10 rounded" />
<div className="flex-1 space-y-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-40" />
</div>
</div>
))}
</div>
);
}
const getDocCount = (type: KBIndexType) => {
if (!stats || !Array.isArray(stats.indexes)) return 0;
const idx = stats.indexes.find((i) => i.index_type === type);
return idx?.document_count ?? 0;
};
return (
<div className="space-y-2">
{Object.values(KBIndexType).map((type) => {
const config = categoryConfig[type];
const count = getDocCount(type);
const isSelected = selectedCategory === type;
return (
<button
key={type}
onClick={() => onSelectCategory(type)}
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors text-left ${
isSelected
? "bg-primary/10 border-primary"
: "hover:bg-muted/50 border-transparent hover:border-border"
}`}
>
<div className="shrink-0">{config.icon}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{config.label}</span>
<span className="text-xs text-muted-foreground">
{count.toLocaleString()} docs
</span>
</div>
<p className="text-xs text-muted-foreground truncate">{config.description}</p>
</div>
<ChevronRight className={`h-4 w-4 text-muted-foreground shrink-0 transition-transform ${isSelected ? "rotate-90" : ""}`} />
</button>
);
})}
</div>
);
}
@@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
import { KBIndexType } from "@/types";
import { useKBDocuments } from "@/hooks/use-knowledge-base";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { KBIndexTypeBadge } from "./kb-index-type-badge";
import { FileCode, FolderOpen, Clock, ChevronLeft, ChevronRight } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
const PAGE_SIZE = 25;
interface KBCategoryViewProps {
category: KBIndexType | null;
}
// Inner component that resets when category changes via key
function KBCategoryViewInner({ category }: { category: KBIndexType }) {
const [page, setPage] = useState(1);
const offset = (page - 1) * PAGE_SIZE;
const { data, isLoading } = useKBDocuments(
category,
{ limit: PAGE_SIZE, offset },
true
);
if (isLoading) {
return (
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center gap-3 p-3 border rounded-lg">
<Skeleton className="h-10 w-10 rounded" />
<div className="flex-1 space-y-1">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
);
}
const documents = data?.documents ?? [];
const totalPages = Math.ceil((data?.total ?? 0) / PAGE_SIZE);
return (
<div className="space-y-4">
{/* Documents List */}
<div className="space-y-2">
{documents.length === 0 ? (
<Card>
<CardContent className="py-8 text-center">
<FileCode className="h-8 w-8 text-muted-foreground/50 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
No documents in this category yet.
</p>
</CardContent>
</Card>
) : (
documents.map((doc) => (
<Card key={doc.id} className="hover:bg-muted/50 transition-colors">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-muted rounded">
<FileCode className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<KBIndexTypeBadge indexType={category} />
</div>
<p className="text-sm font-mono truncate">{doc.source}</p>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>
{formatDistanceToNow(new Date(doc.indexed_at), { addSuffix: true })}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-2">
<p className="text-sm text-muted-foreground">
Page {page} of {totalPages} ({data?.total} documents)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
)}
</div>
);
}
export function KBCategoryView({ category }: KBCategoryViewProps) {
if (!category) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<FolderOpen className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium mb-1">Browse Categories</h3>
<p className="text-sm text-muted-foreground max-w-md">
Select a category from the left to browse indexed documents.
</p>
</div>
);
}
// Key by category to reset state when category changes
return <KBCategoryViewInner key={category} category={category} />;
}
@@ -0,0 +1,75 @@
"use client";
import { KBIndexType } from "@/types";
import { Checkbox } from "@/components/ui/checkbox";
import { Code, FileText, MessageSquare, BookOpen, AlertTriangle, Scale, GitBranch, ClipboardCheck, Lightbulb } from "lucide-react";
const indexTypeConfig: Record<KBIndexType, { label: string; icon: React.ReactNode }> = {
[KBIndexType.CODE]: { label: "Code", icon: <Code className="h-4 w-4 text-purple-500" /> },
[KBIndexType.DOCUMENTATION]: { label: "Documentation", icon: <FileText className="h-4 w-4 text-blue-500" /> },
[KBIndexType.CONVERSATIONS]: { label: "Conversations", icon: <MessageSquare className="h-4 w-4 text-green-500" /> },
[KBIndexType.JOURNALS]: { label: "Journals", icon: <BookOpen className="h-4 w-4 text-orange-500" /> },
[KBIndexType.ERRORS]: { label: "Errors", icon: <AlertTriangle className="h-4 w-4 text-red-500" /> },
[KBIndexType.STANDARDS]: { label: "Standards", icon: <Scale className="h-4 w-4 text-cyan-500" /> },
[KBIndexType.DECISIONS]: { label: "Decisions", icon: <GitBranch className="h-4 w-4 text-indigo-500" /> },
[KBIndexType.REVIEWS]: { label: "Reviews", icon: <ClipboardCheck className="h-4 w-4 text-pink-500" /> },
[KBIndexType.LEARNINGS]: { label: "Learnings", icon: <Lightbulb className="h-4 w-4 text-yellow-500" /> },
};
interface KBFiltersProps {
selectedTypes: KBIndexType[];
onTypesChange: (types: KBIndexType[]) => void;
}
export function KBFilters({ selectedTypes, onTypesChange }: KBFiltersProps) {
const toggleType = (type: KBIndexType) => {
if (selectedTypes.includes(type)) {
onTypesChange(selectedTypes.filter((t) => t !== type));
} else {
onTypesChange([...selectedTypes, type]);
}
};
const allSelected = selectedTypes.length === 0;
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Filter by type</span>
{selectedTypes.length > 0 && (
<button
onClick={() => onTypesChange([])}
className="text-xs text-muted-foreground hover:text-foreground"
>
Clear
</button>
)}
</div>
<div className="space-y-2">
{Object.values(KBIndexType).map((type) => {
const config = indexTypeConfig[type];
const isChecked = allSelected || selectedTypes.includes(type);
return (
<label
key={type}
className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 p-1.5 rounded -ml-1.5"
>
<Checkbox
checked={isChecked}
onCheckedChange={() => toggleType(type)}
/>
{config.icon}
<span className="text-sm">{config.label}</span>
</label>
);
})}
</div>
{!allSelected && (
<p className="text-xs text-muted-foreground">
Showing {selectedTypes.length} of {Object.values(KBIndexType).length} types
</p>
)}
</div>
);
}
@@ -0,0 +1,78 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { KBIndexType } from "@/types";
import { Code, FileText, MessageSquare, BookOpen, AlertTriangle, Scale, GitBranch, ClipboardCheck, Lightbulb } from "lucide-react";
const indexTypeConfig: Record<KBIndexType, { label: string; color: string; icon: React.ReactNode }> = {
[KBIndexType.CODE]: {
label: "Code",
color: "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300",
icon: <Code className="h-3 w-3" />,
},
[KBIndexType.DOCUMENTATION]: {
label: "Docs",
color: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
icon: <FileText className="h-3 w-3" />,
},
[KBIndexType.CONVERSATIONS]: {
label: "Conversations",
color: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
icon: <MessageSquare className="h-3 w-3" />,
},
[KBIndexType.JOURNALS]: {
label: "Journals",
color: "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300",
icon: <BookOpen className="h-3 w-3" />,
},
[KBIndexType.ERRORS]: {
label: "Errors",
color: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
icon: <AlertTriangle className="h-3 w-3" />,
},
[KBIndexType.STANDARDS]: {
label: "Standards",
color: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300",
icon: <Scale className="h-3 w-3" />,
},
[KBIndexType.DECISIONS]: {
label: "Decisions",
color: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300",
icon: <GitBranch className="h-3 w-3" />,
},
[KBIndexType.REVIEWS]: {
label: "Reviews",
color: "bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300",
icon: <ClipboardCheck className="h-3 w-3" />,
},
[KBIndexType.LEARNINGS]: {
label: "Learnings",
color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300",
icon: <Lightbulb className="h-3 w-3" />,
},
};
interface KBIndexTypeBadgeProps {
indexType: KBIndexType;
showIcon?: boolean;
className?: string;
}
export function KBIndexTypeBadge({ indexType, showIcon = true, className }: KBIndexTypeBadgeProps) {
const config = indexTypeConfig[indexType];
return (
<Badge variant="secondary" className={`${config.color} ${className ?? ""}`}>
{showIcon && <span className="mr-1">{config.icon}</span>}
{config.label}
</Badge>
);
}
export function getIndexTypeIcon(indexType: KBIndexType) {
return indexTypeConfig[indexType].icon;
}
export function getIndexTypeLabel(indexType: KBIndexType) {
return indexTypeConfig[indexType].label;
}
@@ -0,0 +1,98 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { KBSearchResult } from "@/types";
import { KBIndexTypeBadge } from "./kb-index-type-badge";
import { ExternalLink, FileCode, Hash } from "lucide-react";
interface KBResultCardProps {
result: KBSearchResult;
onClick?: () => void;
}
export function KBResultCard({ result, onClick }: KBResultCardProps) {
// Truncate content for display (snippet)
const snippet = result.content.length > 300
? result.content.substring(0, 300) + "..."
: result.content;
// Format source for display
const formatSource = (source: string) => {
// Remove common prefixes
if (source.startsWith("channel:")) {
return source.replace("channel:", "# ");
}
if (source.startsWith("journal:")) {
return source.replace("journal:", "Journal: ");
}
// Shorten file paths
if (source.includes("/")) {
const parts = source.split("/");
if (parts.length > 3) {
return ".../" + parts.slice(-2).join("/");
}
}
return source;
};
// Score as percentage
const scorePercent = Math.round(result.score * 100);
return (
<Card
className={`hover:bg-muted/50 transition-colors ${onClick ? "cursor-pointer" : ""}`}
onClick={onClick}
>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
{/* Header */}
<div className="flex items-center gap-2 flex-wrap mb-2">
<KBIndexTypeBadge indexType={result.index_type} />
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Hash className="h-3 w-3" />
{scorePercent}% match
</span>
</div>
{/* Source */}
<div className="flex items-center gap-1 text-sm text-muted-foreground mb-2">
<FileCode className="h-3 w-3 shrink-0" />
<span className="truncate font-mono text-xs">{formatSource(result.source)}</span>
</div>
{/* Content snippet */}
<p className="text-sm text-foreground/90 whitespace-pre-wrap line-clamp-4">
{snippet}
</p>
{/* Metadata */}
{result.metadata && Object.keys(result.metadata).length > 0 && (
<div className="flex items-center gap-2 mt-2 flex-wrap">
{typeof result.metadata.language === "string" && (
<span className="text-xs bg-muted px-1.5 py-0.5 rounded">
{result.metadata.language}
</span>
)}
{typeof result.metadata.agent === "string" && (
<span className="text-xs bg-muted px-1.5 py-0.5 rounded">
@{result.metadata.agent}
</span>
)}
{typeof result.metadata.section === "string" && (
<span className="text-xs bg-muted px-1.5 py-0.5 rounded">
{result.metadata.section}
</span>
)}
</div>
)}
</div>
{onClick && (
<ExternalLink className="h-4 w-4 text-muted-foreground shrink-0" />
)}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,67 @@
"use client";
import { KBSearchResponse } from "@/types";
import { KBResultCard } from "./kb-result-card";
import { Skeleton } from "@/components/ui/skeleton";
import { SearchX, FileSearch } from "lucide-react";
interface KBResultListProps {
response: KBSearchResponse | undefined;
isLoading: boolean;
query: string;
}
export function KBResultList({ response, isLoading, query }: KBResultListProps) {
if (isLoading) {
return (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-4 w-20" />
</div>
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-16 w-full" />
</div>
))}
</div>
);
}
if (!query || query.length < 3) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<FileSearch className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium mb-1">Search the Knowledge Base</h3>
<p className="text-sm text-muted-foreground max-w-md">
Enter at least 3 characters to search across indexed code, documentation,
conversations, and journals.
</p>
</div>
);
}
if (!response || response.results.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<SearchX className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium mb-1">No results found</h3>
<p className="text-sm text-muted-foreground max-w-md">
No matches for &ldquo;{query}&rdquo;. Try different keywords or adjust your filters.
</p>
</div>
);
}
return (
<div className="space-y-3">
<div className="text-sm text-muted-foreground mb-4">
Found {response.total} result{response.total !== 1 ? "s" : ""} for &ldquo;{response.query}&rdquo;
</div>
{response.results.map((result, index) => (
<KBResultCard key={`${result.source}-${index}`} result={result} />
))}
</div>
);
}
@@ -0,0 +1,82 @@
"use client";
import { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search, X, Loader2 } from "lucide-react";
interface KBSearchBarProps {
value: string;
onChange: (value: string) => void;
onSearch?: () => void;
placeholder?: string;
isLoading?: boolean;
debounceMs?: number;
}
export function KBSearchBar({
value,
onChange,
onSearch,
placeholder = "Search knowledge base...",
isLoading = false,
debounceMs = 300,
}: KBSearchBarProps) {
const [localValue, setLocalValue] = useState(value);
// Debounce the onChange callback
useEffect(() => {
const timer = setTimeout(() => {
if (localValue !== value) {
onChange(localValue);
}
}, debounceMs);
return () => clearTimeout(timer);
}, [localValue, debounceMs, onChange, value]);
// Sync external value changes
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && onSearch) {
onChange(localValue);
onSearch();
}
};
const handleClear = () => {
setLocalValue("");
onChange("");
};
return (
<div className="relative flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="pl-9 pr-9"
/>
{localValue && (
<button
onClick={handleClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{onSearch && (
<Button onClick={onSearch} disabled={!localValue || localValue.length < 3 || isLoading}>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Search"}
</Button>
)}
</div>
);
}
@@ -0,0 +1,115 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { KBStats, KBIndexType } from "@/types";
import { Database, Code, FileText, MessageSquare, BookOpen, AlertTriangle, Scale, GitBranch, ClipboardCheck, Lightbulb } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
const indexIcons: Record<KBIndexType, React.ReactNode> = {
[KBIndexType.CODE]: <Code className="h-4 w-4 text-purple-500" />,
[KBIndexType.DOCUMENTATION]: <FileText className="h-4 w-4 text-blue-500" />,
[KBIndexType.CONVERSATIONS]: <MessageSquare className="h-4 w-4 text-green-500" />,
[KBIndexType.JOURNALS]: <BookOpen className="h-4 w-4 text-orange-500" />,
[KBIndexType.ERRORS]: <AlertTriangle className="h-4 w-4 text-red-500" />,
[KBIndexType.STANDARDS]: <Scale className="h-4 w-4 text-cyan-500" />,
[KBIndexType.DECISIONS]: <GitBranch className="h-4 w-4 text-indigo-500" />,
[KBIndexType.REVIEWS]: <ClipboardCheck className="h-4 w-4 text-pink-500" />,
[KBIndexType.LEARNINGS]: <Lightbulb className="h-4 w-4 text-yellow-500" />,
};
const indexLabels: Record<KBIndexType, string> = {
[KBIndexType.CODE]: "Code",
[KBIndexType.DOCUMENTATION]: "Docs",
[KBIndexType.CONVERSATIONS]: "Convos",
[KBIndexType.JOURNALS]: "Journals",
[KBIndexType.ERRORS]: "Errors",
[KBIndexType.STANDARDS]: "Standards",
[KBIndexType.DECISIONS]: "Decisions",
[KBIndexType.REVIEWS]: "Reviews",
[KBIndexType.LEARNINGS]: "Learnings",
};
interface KBStatsCardProps {
stats: KBStats | undefined;
isLoading: boolean;
}
export function KBStatsCard({ stats, isLoading }: KBStatsCardProps) {
if (isLoading) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Database className="h-4 w-4" />
Index Stats
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-10" />
</div>
))}
</CardContent>
</Card>
);
}
if (!stats || !Array.isArray(stats.indexes)) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Database className="h-4 w-4" />
Index Stats
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">No data available</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Database className="h-4 w-4" />
Index Stats
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{stats.indexes.map((idx) => (
<div key={idx.index_type} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
{indexIcons[idx.index_type]}
<span>{indexLabels[idx.index_type]}</span>
</div>
<div className="text-right">
<span className="font-medium">{idx.document_count.toLocaleString()}</span>
<span className="text-muted-foreground text-xs ml-1">docs</span>
</div>
</div>
))}
<div className="pt-2 border-t">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">Total</span>
<span className="font-bold">{stats.total_documents.toLocaleString()}</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground mt-1">
<span>Chunks</span>
<span>{stats.total_chunks.toLocaleString()}</span>
</div>
</div>
{stats.indexes[0]?.last_updated && (
<p className="text-xs text-muted-foreground pt-1">
Updated {formatDistanceToNow(new Date(stats.indexes[0].last_updated))} ago
</p>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,647 @@
"use client";
import { useState, useCallback, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { KBIndexType, RAGQueryResponse } from "@/types";
import {
useKBStats,
useKBSearch,
useRAGQuery,
useRAGHealth,
useDeleteIndex,
useRefreshIndex,
useReindexAll,
useAskMentor,
} from "@/hooks/use-knowledge-base";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Progress } from "@/components/ui/progress";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
RefreshCw,
Search,
Sparkles,
FolderTree,
Settings,
Trash2,
CheckCircle,
XCircle,
Activity,
HardDrive,
FileText,
Brain,
} from "lucide-react";
import { OfflineState } from "@/components/ui/offline-state";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import { formatDistanceToNow } from "date-fns";
import { getErrorMessage } from "@/lib/api/client";
// Components
import { KBSearchBar } from "./kb-search-bar";
import { KBFilters } from "./kb-filters";
import { KBStatsCard } from "./kb-stats-card";
import { KBResultList } from "./kb-result-list";
import { RAGQueryInput } from "./rag-query-input";
import { RAGAnswerDisplay } from "./rag-answer-display";
import { MentorChat } from "./mentor-chat";
import { KBCategoryNav } from "./kb-category-nav";
import { KBCategoryView } from "./kb-category-view";
type TabValue = "search" | "ask" | "mentor" | "browse" | "admin";
const INDEX_LABELS: Record<KBIndexType, string> = {
[KBIndexType.CODE]: "Codebase",
[KBIndexType.DOCUMENTATION]: "Documentation",
[KBIndexType.CONVERSATIONS]: "Conversations",
[KBIndexType.JOURNALS]: "Agent Journals",
[KBIndexType.ERRORS]: "Error Solutions",
[KBIndexType.STANDARDS]: "Standards",
[KBIndexType.DECISIONS]: "Decisions",
[KBIndexType.REVIEWS]: "Code Reviews",
[KBIndexType.LEARNINGS]: "Learnings",
};
// Valid KB index types for URL param validation
const VALID_INDEX_TYPES: KBIndexType[] = [
KBIndexType.CODE,
KBIndexType.DOCUMENTATION,
KBIndexType.CONVERSATIONS,
KBIndexType.JOURNALS,
KBIndexType.ERRORS,
KBIndexType.STANDARDS,
KBIndexType.DECISIONS,
KBIndexType.REVIEWS,
KBIndexType.LEARNINGS,
];
function KnowledgeBaseBrowserContent() {
const router = useRouter();
const searchParams = useSearchParams();
// Read state from URL params
const activeTab = (searchParams.get("tab") as TabValue) || "search";
const searchQuery = searchParams.get("q") || "";
const filtersParam = searchParams.get("filters");
const searchFilters: KBIndexType[] = filtersParam
? (filtersParam.split(",").filter((f) => VALID_INDEX_TYPES.includes(f as KBIndexType)) as KBIndexType[])
: [];
const selectedCategory = (searchParams.get("category") as KBIndexType) || null;
// RAG state (transient, not URL-persisted)
const [ragQuestion, setRagQuestion] = useState<string | null>(null);
const [ragResponse, setRagResponse] = useState<RAGQueryResponse | null>(null);
const [ragError, setRagError] = useState<string | null>(null);
// Update URL params helper
const updateParams = useCallback(
(updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
});
const query = params.toString();
router.push(query ? `/knowledge-base?${query}` : "/knowledge-base");
},
[router, searchParams]
);
// State update handlers
const handleTabChange = useCallback(
(tab: TabValue) => {
updateParams({ tab: tab === "search" ? null : tab });
},
[updateParams]
);
const handleSearchChange = useCallback(
(query: string) => {
updateParams({ q: query || null });
},
[updateParams]
);
const handleFiltersChange = useCallback(
(filters: KBIndexType[]) => {
updateParams({ filters: filters.length > 0 ? filters.join(",") : null });
},
[updateParams]
);
const handleCategoryChange = useCallback(
(category: KBIndexType | null) => {
updateParams({ category });
},
[updateParams]
);
// Data hooks
const { data: stats, isLoading: statsLoading, error: statsError, refetch: refetchStats } = useKBStats();
const { data: searchResults, isLoading: searchLoading } = useKBSearch({
query: searchQuery,
index_types: searchFilters.length > 0 ? searchFilters : undefined,
});
const ragMutation = useRAGQuery();
// Admin hooks
const { data: health, isLoading: loadingHealth, refetch: refetchHealth } = useRAGHealth();
const deleteIndex = useDeleteIndex();
const refreshIndex = useRefreshIndex();
const reindexAll = useReindexAll();
const askMentor = useAskMentor();
// Handle RAG query
const handleRAGQuery = async (question: string) => {
setRagQuestion(question);
setRagError(null);
setRagResponse(null);
try {
const response = await ragMutation.mutateAsync({ question });
setRagResponse(response);
} catch (error) {
console.error("RAG query failed:", error);
const errorMessage = getErrorMessage(error);
setRagError(errorMessage);
toast.error(`RAG query failed: ${errorMessage}`);
}
};
// Admin handlers
const handleDeleteIndex = async (indexType: KBIndexType) => {
try {
await deleteIndex.mutateAsync(indexType);
toast.success(`Deleted ${INDEX_LABELS[indexType]} index`);
} catch (error) {
toast.error(`Failed to delete index: ${getErrorMessage(error)}`);
}
};
const handleRefreshIndex = async (indexType: KBIndexType) => {
try {
await refreshIndex.mutateAsync({ index_type: indexType, sources: [] });
toast.success(`Refreshing ${INDEX_LABELS[indexType]} index`);
} catch (error) {
toast.error(`Failed to refresh index: ${getErrorMessage(error)}`);
}
};
const handleReindexAll = async () => {
try {
const result = await reindexAll.mutateAsync({ force: true });
// Show detailed results if available
if (result.overall_success) {
const codeCount = result.code?.successful ?? 0;
const docsCount = result.documentation?.successful ?? 0;
toast.success(
`Reindexed ${codeCount} code files, ${docsCount} docs. ` +
`${result.warnings?.length ? `Warnings: ${result.warnings.length}` : ""}`
);
} else {
toast.warning(
`Reindex completed with issues: ${result.warnings?.join(", ") ?? "Unknown errors"}`
);
}
} catch (error) {
toast.error(`Failed to reindex: ${getErrorMessage(error)}`);
}
};
// Mentor ask function for chat component
const handleMentorAsk = async (question: string, conversationId?: string) => {
try {
return await askMentor.mutateAsync({
question,
conversation_id: conversationId,
});
} catch (error) {
toast.error(`Mentor query failed: ${getErrorMessage(error)}`);
throw error;
}
};
const handleRefresh = () => {
refetchStats();
refetchHealth();
};
// Calculate totals for admin
const totalDocs = stats?.indexes.reduce((sum, idx) => sum + idx.document_count, 0) ?? 0;
const totalChunks = stats?.indexes.reduce((sum, idx) => sum + idx.chunk_count, 0) ?? 0;
// Check if offline
const isOffline = statsError && (
statsError.message?.includes("Network Error") ||
(statsError as { code?: string })?.code === "ERR_NETWORK"
);
if (isOffline) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Knowledge Base</h1>
<p className="text-muted-foreground">
Search and query indexed knowledge
</p>
</div>
</div>
<OfflineState
title="Cannot Connect to Knowledge Base"
description="Start the RoboCo orchestrator to access the knowledge base."
onRetry={() => refetchStats()}
/>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Knowledge Base</h1>
<p className="text-muted-foreground">
Search and query indexed knowledge
</p>
</div>
<Button variant="outline" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={(v) => handleTabChange(v as TabValue)}>
<TabsList>
<TabsTrigger value="search" className="gap-2">
<Search className="h-4 w-4" />
Search
</TabsTrigger>
<TabsTrigger value="ask" className="gap-2">
<Sparkles className="h-4 w-4" />
Ask AI
</TabsTrigger>
<TabsTrigger value="mentor" className="gap-2">
<Brain className="h-4 w-4" />
Mentor
</TabsTrigger>
<TabsTrigger value="browse" className="gap-2">
<FolderTree className="h-4 w-4" />
Browse
</TabsTrigger>
<TabsTrigger value="admin" className="gap-2">
<Settings className="h-4 w-4" />
Admin
</TabsTrigger>
</TabsList>
{/* Search Tab */}
<TabsContent value="search" className="mt-6">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Sidebar */}
<div className="lg:col-span-1 space-y-4">
<Card>
<CardContent className="pt-4">
<KBFilters
selectedTypes={searchFilters}
onTypesChange={handleFiltersChange}
/>
</CardContent>
</Card>
<KBStatsCard stats={stats} isLoading={statsLoading} />
</div>
{/* Main content */}
<div className="lg:col-span-3 space-y-4">
<KBSearchBar
value={searchQuery}
onChange={handleSearchChange}
isLoading={searchLoading}
/>
<ScrollArea className="h-[calc(100vh-380px)]">
<KBResultList
response={searchResults}
isLoading={searchLoading}
query={searchQuery}
/>
</ScrollArea>
</div>
</div>
</TabsContent>
{/* Ask AI Tab */}
<TabsContent value="ask" className="mt-6">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Sidebar */}
<div className="lg:col-span-1">
<KBStatsCard stats={stats} isLoading={statsLoading} />
</div>
{/* Main content */}
<div className="lg:col-span-3 space-y-6">
<RAGQueryInput
onSubmit={handleRAGQuery}
isLoading={ragMutation.isPending}
/>
<ScrollArea className="h-[calc(100vh-450px)]">
<RAGAnswerDisplay
response={ragResponse}
isLoading={ragMutation.isPending}
question={ragQuestion}
error={ragError}
/>
</ScrollArea>
</div>
</div>
</TabsContent>
{/* Mentor Tab - Chat Interface */}
<TabsContent value="mentor" className="mt-6">
<MentorChat
onAsk={handleMentorAsk}
isLoading={askMentor.isPending}
/>
</TabsContent>
{/* Browse Tab */}
<TabsContent value="browse" className="mt-6">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Sidebar */}
<div className="lg:col-span-1">
<KBCategoryNav
stats={stats}
isLoading={statsLoading}
selectedCategory={selectedCategory}
onSelectCategory={handleCategoryChange}
/>
</div>
{/* Main content */}
<div className="lg:col-span-3">
<ScrollArea className="h-[calc(100vh-320px)]">
<KBCategoryView category={selectedCategory} />
</ScrollArea>
</div>
</div>
</TabsContent>
{/* Admin Tab */}
<TabsContent value="admin" className="mt-6">
<div className="grid grid-cols-12 gap-6">
{/* Left Column - Health & Mentor */}
<div className="col-span-12 lg:col-span-4 space-y-4">
{/* Health Card */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Activity className="h-4 w-4" />
System Health
</CardTitle>
</CardHeader>
<CardContent>
{loadingHealth ? (
<Skeleton className="h-20 w-full" />
) : health ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
{health.healthy ? (
<CheckCircle className="h-8 w-8 text-green-600" />
) : (
<XCircle className="h-8 w-8 text-red-600" />
)}
<div>
<p className="font-medium">{health.healthy ? "Healthy" : "Unhealthy"}</p>
<p className="text-sm text-muted-foreground">
Embedding: {health.embedding_status}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
<div>LLM: {health.llm_status}</div>
<div>Vector: {health.vector_store_status}</div>
</div>
</div>
) : (
<p className="text-muted-foreground text-sm">Health data unavailable</p>
)}
</CardContent>
</Card>
{/* Summary Stats */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center justify-between">
<span>Summary</span>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive">
<RefreshCw className="h-3 w-3 mr-1" />
Reindex All
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reindex All Data?</AlertDialogTitle>
<AlertDialogDescription>
This will rebuild all indexes from scratch. This may take several minutes.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleReindexAll} disabled={reindexAll.isPending}>
{reindexAll.isPending && <RefreshCw className="h-4 w-4 mr-2 animate-spin" />}
Reindex
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-2xl font-bold">{totalDocs}</p>
<p className="text-xs text-muted-foreground">Documents</p>
</div>
<div>
<p className="text-2xl font-bold">{totalChunks}</p>
<p className="text-xs text-muted-foreground">Chunks</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Right Column - Index Management */}
<div className="col-span-12 lg:col-span-8">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<HardDrive className="h-4 w-4" />
Index Management
</CardTitle>
</CardHeader>
<CardContent>
{statsLoading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : (
<ScrollArea className="h-[450px]">
<div className="space-y-3 pr-4">
{stats?.indexes.map((index) => {
const indexType = index.index_type;
const percentage = totalChunks > 0
? Math.round((index.chunk_count / totalChunks) * 100)
: 0;
return (
<div
key={indexType}
className="border rounded-lg p-4 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{INDEX_LABELS[indexType]}</span>
<Badge variant="outline" className="text-xs">
{indexType}
</Badge>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleRefreshIndex(indexType)}
disabled={refreshIndex.isPending}
>
{refreshIndex.isPending ? (
<RefreshCw className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline" className="text-red-600">
<Trash2 className="h-3 w-3" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {INDEX_LABELS[indexType]}?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete all {index.document_count} documents
and {index.chunk_count} chunks from this index.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteIndex(indexType)}
className="bg-red-600 hover:bg-red-700"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className="grid grid-cols-3 gap-4 text-sm mb-2">
<div>
<span className="text-muted-foreground">Documents:</span>{" "}
<span className="font-medium">{index.document_count}</span>
</div>
<div>
<span className="text-muted-foreground">Chunks:</span>{" "}
<span className="font-medium">{index.chunk_count}</span>
</div>
<div>
<span className="text-muted-foreground">Updated:</span>{" "}
<span className="font-medium">
{index.last_updated
? formatDistanceToNow(new Date(index.last_updated)) + " ago"
: "Never"}
</span>
</div>
</div>
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>Storage usage</span>
<span>{percentage}%</span>
</div>
<Progress value={percentage} className="h-1" />
</div>
</div>
);
})}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
</div>
</div>
</TabsContent>
</Tabs>
</div>
);
}
// Loading skeleton for Suspense fallback
function KnowledgeBaseBrowserSkeleton() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-48 mb-2" />
<Skeleton className="h-5 w-64" />
</div>
<Skeleton className="h-10 w-24" />
</div>
<Skeleton className="h-10 w-64" />
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div className="lg:col-span-1 space-y-4">
<Skeleton className="h-40 w-full" />
<Skeleton className="h-32 w-full" />
</div>
<div className="lg:col-span-3 space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
</div>
</div>
);
}
// Wrap in Suspense for useSearchParams
export function KnowledgeBaseBrowser() {
return (
<Suspense fallback={<KnowledgeBaseBrowserSkeleton />}>
<KnowledgeBaseBrowserContent />
</Suspense>
);
}
@@ -0,0 +1,256 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { MentorAskResponse } from "@/types";
import { RAGCitationCard } from "./rag-citation-card";
import { Markdown } from "@/components/ui/markdown";
import { Skeleton } from "@/components/ui/skeleton";
import {
Brain,
BookOpen,
MessageSquareText,
Sparkles,
AlertCircle,
Lightbulb,
BarChart3,
MessageCircleQuestion,
User,
BookMarked,
} from "lucide-react";
interface MentorAnswerDisplayProps {
response: MentorAskResponse | null;
isLoading: boolean;
question: string | null;
error?: string | null;
onFollowUp?: (question: string) => void;
}
export function MentorAnswerDisplay({
response,
isLoading,
question,
error,
onFollowUp,
}: MentorAnswerDisplayProps) {
if (isLoading) {
return (
<div className="space-y-4">
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded-full" />
<Skeleton className="h-5 w-32" />
</div>
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</CardContent>
</Card>
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
</div>
);
}
if (!response && !question && !error) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="relative mb-4">
<Brain className="h-12 w-12 text-muted-foreground/50" />
<Sparkles className="h-5 w-5 text-yellow-500 absolute -top-1 -right-1" />
</div>
<h3 className="text-lg font-medium mb-1">AI Mentor</h3>
<p className="text-sm text-muted-foreground max-w-md">
Your personalized AI mentor that knows your role and past experiences.
Ask questions about standards, workflows, or get guidance on your tasks.
</p>
<div className="flex gap-2 mt-4 flex-wrap justify-center">
<Badge variant="outline" className="text-xs">Role-aware</Badge>
<Badge variant="outline" className="text-xs">Personal context</Badge>
<Badge variant="outline" className="text-xs">Follow-ups</Badge>
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-4">
{question && (
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center shrink-0">
<MessageSquareText className="h-4 w-4" />
</div>
<div className="flex-1 pt-1">
<p className="text-sm font-medium">{question}</p>
</div>
</div>
)}
<Card className="border-red-500/50 bg-red-50 dark:bg-red-950/20">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2 text-red-600 dark:text-red-400">
<AlertCircle className="h-4 w-4" />
Mentor Query Failed
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</CardContent>
</Card>
</div>
);
}
if (!response) {
return null;
}
// Calculate total sources searched
const totalSearched = response.search_stats
? Object.values(response.search_stats).reduce((sum, count) => sum + (count > 0 ? count : 0), 0)
: 0;
return (
<div className="space-y-4">
{/* Question */}
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center shrink-0">
<MessageSquareText className="h-4 w-4" />
</div>
<div className="flex-1 pt-1">
<p className="text-sm font-medium">{question}</p>
</div>
</div>
{/* Personalization Context - What makes this different from Ask AI */}
{(response.agent_role || response.journal_entries_used) && (
<div className="flex items-center gap-3 p-3 bg-primary/5 rounded-lg border border-primary/20">
<Brain className="h-5 w-5 text-primary shrink-0" />
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
{response.agent_role && (
<span className="flex items-center gap-1.5">
<User className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Role:</span>
<Badge variant="secondary" className="font-medium">
{response.agent_role}
</Badge>
{response.agent_team && (
<span className="text-muted-foreground">({response.agent_team})</span>
)}
</span>
)}
{response.journal_entries_used !== undefined && response.journal_entries_used > 0 && (
<span className="flex items-center gap-1.5">
<BookMarked className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">
{response.journal_entries_used} personal journal{response.journal_entries_used !== 1 ? "s" : ""} used
</span>
</span>
)}
</div>
</div>
)}
{/* Answer */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Brain className="h-4 w-4 text-primary" />
Mentor Response
<span className="text-xs text-muted-foreground font-normal ml-auto">
{response.sources.length} sources used
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="prose prose-sm dark:prose-invert max-w-none">
<Markdown>{response.answer}</Markdown>
</div>
</CardContent>
</Card>
{/* Suggested Follow-ups */}
{response.suggested_followups && response.suggested_followups.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Lightbulb className="h-4 w-4 text-yellow-500" />
Follow-up Questions
</div>
<div className="flex flex-wrap gap-2">
{response.suggested_followups.map((followup, index) => (
<Button
key={index}
variant="outline"
size="sm"
className="text-xs h-auto py-1.5 px-3"
onClick={() => onFollowUp?.(followup)}
>
<MessageCircleQuestion className="h-3 w-3 mr-1.5" />
{followup}
</Button>
))}
</div>
</div>
)}
{/* Search Stats */}
{response.search_stats && Object.keys(response.search_stats).length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<BarChart3 className="h-4 w-4 text-muted-foreground" />
Search Stats
<span className="text-xs text-muted-foreground font-normal">
({totalSearched} total results)
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{Object.entries(response.search_stats).map(([indexType, count]) => (
<Badge
key={indexType}
variant={count > 0 ? "secondary" : "outline"}
className={`text-xs ${count === -1 ? "text-red-500" : ""}`}
>
{indexType}: {count === -1 ? "error" : count}
</Badge>
))}
</div>
</div>
)}
{/* Citations */}
{response.sources.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<BookOpen className="h-4 w-4 text-muted-foreground" />
Sources ({response.sources.length})
</div>
<div className="space-y-2">
{response.sources.map((source, index) => (
<RAGCitationCard
key={index}
citation={{
content: source.content,
source: source.source,
score: source.score,
index_type: source.index_type,
metadata: source.metadata,
}}
index={index}
/>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,312 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Markdown } from "@/components/ui/markdown";
import {
Send,
Loader2,
Brain,
User,
BookMarked,
MessageCircle,
RotateCcw,
Sparkles,
} from "lucide-react";
import { MentorAskResponse, KBSearchResult } from "@/types";
import { RAGCitationCard } from "./rag-citation-card";
interface ChatMessage {
role: "user" | "mentor";
content: string;
sources?: KBSearchResult[];
followups?: string[];
agentRole?: string;
agentTeam?: string;
journalEntriesUsed?: number;
}
interface MentorChatProps {
onAsk: (question: string, conversationId?: string) => Promise<MentorAskResponse>;
isLoading: boolean;
}
export function MentorChat({ onAsk, isLoading }: MentorChatProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [conversationId, setConversationId] = useState<string | null>(null);
const [expandedSources, setExpandedSources] = useState<number | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, isLoading]);
const handleSubmit = async (question?: string) => {
const q = question || input.trim();
if (!q || isLoading) return;
// Add user message
setMessages((prev) => [...prev, { role: "user", content: q }]);
setInput("");
try {
const response = await onAsk(q, conversationId ?? undefined);
// Save conversation ID for follow-ups
setConversationId(response.conversation_id);
// Add mentor response
setMessages((prev) => [
...prev,
{
role: "mentor",
content: response.answer,
sources: response.sources,
followups: response.suggested_followups,
agentRole: response.agent_role,
agentTeam: response.agent_team,
journalEntriesUsed: response.journal_entries_used,
},
]);
} catch {
// Error handled by parent
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleNewChat = () => {
setMessages([]);
setConversationId(null);
setExpandedSources(null);
inputRef.current?.focus();
};
const handleFollowUp = (question: string) => {
handleSubmit(question);
};
// Empty state
if (messages.length === 0 && !isLoading) {
return (
<div className="flex flex-col h-[calc(100vh-280px)]">
{/* Empty state */}
<div className="flex-1 flex items-center justify-center">
<div className="text-center max-w-md">
<div className="relative inline-block mb-4">
<Brain className="h-16 w-16 text-primary/50" />
<Sparkles className="h-6 w-6 text-yellow-500 absolute -top-1 -right-1" />
</div>
<h2 className="text-xl font-semibold mb-2">AI Mentor</h2>
<p className="text-muted-foreground mb-4">
Your personalized mentor that knows your role, searches your journals,
and provides tailored guidance. Start a conversation below.
</p>
<div className="flex justify-center gap-2 flex-wrap">
<Badge variant="secondary" className="gap-1">
<User className="h-3 w-3" /> Role-aware
</Badge>
<Badge variant="secondary" className="gap-1">
<BookMarked className="h-3 w-3" /> Personal context
</Badge>
<Badge variant="secondary" className="gap-1">
<MessageCircle className="h-3 w-3" /> Multi-turn chat
</Badge>
</div>
</div>
</div>
{/* Input at bottom */}
<div className="border-t pt-4">
<div className="relative">
<Textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask your mentor a question..."
className="min-h-20 pr-12 resize-none"
disabled={isLoading}
/>
<Button
size="icon"
className="absolute bottom-2 right-2"
onClick={() => handleSubmit()}
disabled={!input.trim() || isLoading}
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col h-[calc(100vh-280px)]">
{/* Header with New Chat */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Brain className="h-5 w-5 text-primary" />
<span className="font-medium">Mentor Chat</span>
{conversationId && (
<Badge variant="outline" className="text-xs">
Conversation active
</Badge>
)}
</div>
<Button variant="ghost" size="sm" onClick={handleNewChat}>
<RotateCcw className="h-4 w-4 mr-1" />
New Chat
</Button>
</div>
{/* Messages */}
<ScrollArea className="flex-1 pr-4" ref={scrollRef}>
<div className="space-y-4">
{messages.map((msg, idx) => (
<div key={idx}>
{msg.role === "user" ? (
// User message
<div className="flex justify-end">
<div className="bg-primary text-primary-foreground rounded-lg px-4 py-2 max-w-[80%]">
<p className="text-sm">{msg.content}</p>
</div>
</div>
) : (
// Mentor message
<div className="space-y-3">
{/* Personalization badge */}
{(msg.agentRole || msg.journalEntriesUsed) && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Brain className="h-3 w-3" />
{msg.agentRole && (
<span>
Role: <Badge variant="secondary" className="text-xs">{msg.agentRole}</Badge>
{msg.agentTeam && <span className="ml-1">({msg.agentTeam})</span>}
</span>
)}
{msg.journalEntriesUsed !== undefined && msg.journalEntriesUsed > 0 && (
<span className="flex items-center gap-1">
<BookMarked className="h-3 w-3" />
{msg.journalEntriesUsed} journal{msg.journalEntriesUsed !== 1 ? "s" : ""} used
</span>
)}
</div>
)}
{/* Answer */}
<Card>
<CardContent className="pt-4">
<div className="prose prose-sm dark:prose-invert max-w-none">
<Markdown>{msg.content}</Markdown>
</div>
</CardContent>
</Card>
{/* Sources toggle */}
{msg.sources && msg.sources.length > 0 && (
<div>
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() => setExpandedSources(expandedSources === idx ? null : idx)}
>
{expandedSources === idx ? "Hide" : "Show"} {msg.sources.length} sources
</Button>
{expandedSources === idx && (
<div className="mt-2 space-y-2">
{msg.sources.map((source, sidx) => (
<RAGCitationCard
key={sidx}
citation={{
content: source.content,
source: source.source,
score: source.score,
index_type: source.index_type,
metadata: source.metadata,
}}
index={sidx}
/>
))}
</div>
)}
</div>
)}
{/* Follow-ups */}
{msg.followups && msg.followups.length > 0 && (
<div className="flex flex-wrap gap-2">
{msg.followups.map((followup, fidx) => (
<Button
key={fidx}
variant="outline"
size="sm"
className="text-xs h-auto py-1.5"
onClick={() => handleFollowUp(followup)}
disabled={isLoading}
>
{followup}
</Button>
))}
</div>
)}
</div>
)}
</div>
))}
{/* Loading indicator */}
{isLoading && (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Mentor is thinking...</span>
</div>
)}
</div>
</ScrollArea>
{/* Input at bottom */}
<div className="border-t pt-4 mt-4">
<div className="relative">
<Textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Continue the conversation..."
className="min-h-16 pr-12 resize-none"
disabled={isLoading}
/>
<Button
size="icon"
className="absolute bottom-2 right-2"
onClick={() => handleSubmit()}
disabled={!input.trim() || isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Send, Loader2, Brain, User, BookMarked, MessageCircle } from "lucide-react";
interface MentorQueryInputProps {
onSubmit: (question: string) => void;
isLoading: boolean;
}
export function MentorQueryInput({ onSubmit, isLoading }: MentorQueryInputProps) {
const [question, setQuestion] = useState("");
const handleSubmit = () => {
if (question.trim() && !isLoading) {
onSubmit(question.trim());
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
return (
<Card className="border-primary/30 bg-primary/5">
<CardContent className="pt-4 space-y-4">
{/* Header - Clearly different from Ask AI */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="p-2 rounded-full bg-primary/20">
<Brain className="h-5 w-5 text-primary" />
</div>
<div>
<h3 className="font-semibold text-sm">Personal Mentor</h3>
<p className="text-xs text-muted-foreground">
Tailored to your role & experience
</p>
</div>
</div>
<div className="flex gap-1.5">
<Badge variant="outline" className="text-xs gap-1 bg-background">
<User className="h-3 w-3" />
Role-aware
</Badge>
<Badge variant="outline" className="text-xs gap-1 bg-background">
<BookMarked className="h-3 w-3" />
Journals
</Badge>
<Badge variant="outline" className="text-xs gap-1 bg-background">
<MessageCircle className="h-3 w-3" />
Follow-ups
</Badge>
</div>
</div>
{/* Input */}
<div className="relative">
<Textarea
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask your mentor about standards, workflows, or get guidance..."
className="min-h-24 pr-12 resize-none bg-background"
disabled={isLoading}
/>
<Button
size="icon"
className="absolute bottom-2 right-2"
onClick={handleSubmit}
disabled={!question.trim() || isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Press Enter to send, Shift+Enter for new line
</p>
</CardContent>
</Card>
);
}
@@ -0,0 +1,139 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { RAGQueryResponse } from "@/types";
import { RAGCitationCard } from "./rag-citation-card";
import { Markdown } from "@/components/ui/markdown";
import { Skeleton } from "@/components/ui/skeleton";
import { Bot, BookOpen, MessageSquareText, Sparkles, AlertCircle } from "lucide-react";
interface RAGAnswerDisplayProps {
response: RAGQueryResponse | null;
isLoading: boolean;
question: string | null;
error?: string | null;
}
export function RAGAnswerDisplay({ response, isLoading, question, error }: RAGAnswerDisplayProps) {
if (isLoading) {
return (
<div className="space-y-4">
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded-full" />
<Skeleton className="h-5 w-32" />
</div>
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</CardContent>
</Card>
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
</div>
);
}
if (!response && !question && !error) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="relative mb-4">
<Bot className="h-12 w-12 text-muted-foreground/50" />
<Sparkles className="h-5 w-5 text-yellow-500 absolute -top-1 -right-1" />
</div>
<h3 className="text-lg font-medium mb-1">Ask AI</h3>
<p className="text-sm text-muted-foreground max-w-md">
Ask questions about your codebase, documentation, or past conversations.
The AI will provide answers with citations from the knowledge base.
</p>
</div>
);
}
if (error) {
return (
<div className="space-y-4">
{question && (
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center shrink-0">
<MessageSquareText className="h-4 w-4" />
</div>
<div className="flex-1 pt-1">
<p className="text-sm font-medium">{question}</p>
</div>
</div>
)}
<Card className="border-red-500/50 bg-red-50 dark:bg-red-950/20">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2 text-red-600 dark:text-red-400">
<AlertCircle className="h-4 w-4" />
Query Failed
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</CardContent>
</Card>
</div>
);
}
if (!response) {
return null;
}
return (
<div className="space-y-4">
{/* Question */}
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center shrink-0">
<MessageSquareText className="h-4 w-4" />
</div>
<div className="flex-1 pt-1">
<p className="text-sm font-medium">{response.query}</p>
</div>
</div>
{/* Answer */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Bot className="h-4 w-4 text-primary" />
AI Answer
<span className="text-xs text-muted-foreground font-normal ml-auto">
{response.context_used} sources used
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="prose prose-sm dark:prose-invert max-w-none">
<Markdown>{response.answer}</Markdown>
</div>
</CardContent>
</Card>
{/* Citations */}
{response.citations.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<BookOpen className="h-4 w-4 text-muted-foreground" />
Citations ({response.citations.length})
</div>
<div className="space-y-2">
{response.citations.map((citation, index) => (
<RAGCitationCard key={index} citation={citation} index={index} />
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,59 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { RAGCitation } from "@/types";
import { KBIndexTypeBadge } from "./kb-index-type-badge";
import { Quote, Hash } from "lucide-react";
interface RAGCitationCardProps {
citation: RAGCitation;
index: number;
}
export function RAGCitationCard({ citation, index }: RAGCitationCardProps) {
// Truncate content
const snippet = citation.content.length > 200
? citation.content.substring(0, 200) + "..."
: citation.content;
// Format source
const formatSource = (source: string) => {
if (source.includes("/")) {
const parts = source.split("/");
if (parts.length > 3) {
return ".../" + parts.slice(-2).join("/");
}
}
return source;
};
const scorePercent = Math.round(citation.score * 100);
return (
<Card className="bg-muted/30">
<CardContent className="p-3">
<div className="flex items-start gap-2">
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-xs font-medium shrink-0">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<KBIndexTypeBadge indexType={citation.index_type} className="text-xs" />
<span className="text-xs text-muted-foreground flex items-center gap-0.5">
<Hash className="h-3 w-3" />
{scorePercent}%
</span>
</div>
<p className="text-xs text-muted-foreground font-mono truncate mb-1">
{formatSource(citation.source)}
</p>
<div className="flex items-start gap-1">
<Quote className="h-3 w-3 text-muted-foreground shrink-0 mt-0.5" />
<p className="text-sm text-foreground/80 line-clamp-3">{snippet}</p>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,67 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Send, Loader2, Sparkles } from "lucide-react";
interface RAGQueryInputProps {
onSubmit: (question: string) => void;
isLoading: boolean;
placeholder?: string;
}
export function RAGQueryInput({
onSubmit,
isLoading,
placeholder = "Ask a question about the codebase, documentation, or conversations...",
}: RAGQueryInputProps) {
const [question, setQuestion] = useState("");
const handleSubmit = () => {
if (question.trim() && !isLoading) {
onSubmit(question.trim());
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
return (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="h-4 w-4 text-yellow-500" />
<span>AI-powered answers with citations from your knowledge base</span>
</div>
<div className="relative">
<Textarea
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="min-h-24 pr-12 resize-none"
disabled={isLoading}
/>
<Button
size="icon"
className="absolute bottom-2 right-2"
onClick={handleSubmit}
disabled={!question.trim() || isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Press Enter to send, Shift+Enter for new line
</p>
</div>
);
}