mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
I mean, it's at a good place rn...
This commit is contained in:
@@ -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 “{query}”. 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 “{response.query}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user