add agent context
This commit is contained in:
@@ -1,16 +1,15 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
FileText,
|
||||
Building,
|
||||
Calendar,
|
||||
AlertCircle,
|
||||
TrendingUp,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Shield,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Severity = "Pass" | "Warning" | "Error";
|
||||
|
||||
@@ -45,42 +44,45 @@ interface AuditReportProps {
|
||||
data: AuditReportData;
|
||||
}
|
||||
|
||||
const getSeverityIcon = (severity: Severity) => {
|
||||
const getSeverityIcon = (severity: Severity, size = "w-4 h-4") => {
|
||||
switch (severity) {
|
||||
case "Pass":
|
||||
return <CheckCircle className="w-5 h-5 text-green-600" />;
|
||||
return <CheckCircle className={`${size} text-green-600`} />;
|
||||
case "Warning":
|
||||
return <AlertTriangle className="w-5 h-5 text-yellow-600" />;
|
||||
return <AlertTriangle className={`${size} text-yellow-600`} />;
|
||||
case "Error":
|
||||
return <XCircle className="w-5 h-5 text-red-600" />;
|
||||
return <XCircle className={`${size} text-red-600`} />;
|
||||
default:
|
||||
return <AlertCircle className="w-5 h-5 text-gray-600" />;
|
||||
return <AlertCircle className={`${size} text-gray-600`} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity: Severity) => {
|
||||
switch (severity) {
|
||||
case "Pass":
|
||||
return "text-green-700 bg-green-50 border-green-200";
|
||||
case "Warning":
|
||||
return "text-yellow-700 bg-yellow-50 border-yellow-200";
|
||||
case "Error":
|
||||
return "text-red-700 bg-red-50 border-red-200";
|
||||
default:
|
||||
return "text-gray-700 bg-gray-50 border-gray-200";
|
||||
}
|
||||
};
|
||||
const getSeverityBadge = (severity: Severity) => {
|
||||
const colors = {
|
||||
Pass: "bg-green-100 text-green-800 border-green-200",
|
||||
Warning: "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||
Error: "bg-red-100 text-red-800 border-red-200",
|
||||
};
|
||||
|
||||
const getConfidenceColor = (confidence: number) => {
|
||||
if (confidence >= 0.8) return "text-green-600";
|
||||
if (confidence >= 0.6) return "text-yellow-600";
|
||||
return "text-red-600";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium border ${colors[severity]}`}
|
||||
>
|
||||
{getSeverityIcon(severity, "w-3 h-3")}
|
||||
{severity}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const AuditReport: React.FC<AuditReportProps> = ({ data }) => {
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [showAllFindings, setShowAllFindings] = useState(false);
|
||||
|
||||
const {
|
||||
organisation_ein,
|
||||
organisation_name,
|
||||
organisation_ein,
|
||||
year,
|
||||
overall_severity,
|
||||
findings,
|
||||
@@ -95,186 +97,227 @@ export const AuditReport: React.FC<AuditReportProps> = ({ data }) => {
|
||||
Error: findings.filter((f) => f.severity === "Error").length,
|
||||
};
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
const newExpanded = new Set(expandedSections);
|
||||
if (newExpanded.has(section)) {
|
||||
newExpanded.delete(section);
|
||||
} else {
|
||||
newExpanded.add(section);
|
||||
}
|
||||
setExpandedSections(newExpanded);
|
||||
};
|
||||
|
||||
const criticalFindings = findings.filter((f) => f.severity === "Error");
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-gray-200 p-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Shield className="w-6 h-6 text-blue-600" />
|
||||
{/* Compact Header */}
|
||||
<div className="bg-linear-to-r from-blue-50 to-indigo-50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-6 h-6 text-blue-600" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">
|
||||
{organisation_name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-600">
|
||||
<span>EIN: {organisation_ein}</span>
|
||||
{year && <span>{year}</span>}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Building className="w-5 h-5" />
|
||||
{organisation_name}
|
||||
</h2>
|
||||
<div className="flex items-center gap-4 mt-1 text-sm text-gray-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="w-4 h-4" />
|
||||
EIN: {organisation_ein}
|
||||
</span>
|
||||
{year && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{year}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overall Status */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-full border",
|
||||
getSeverityColor(overall_severity),
|
||||
)}
|
||||
>
|
||||
{getSeverityIcon(overall_severity)}
|
||||
<span className="font-medium">{overall_severity}</span>
|
||||
</div>
|
||||
</div>
|
||||
{getSeverityBadge(overall_severity)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics Bar */}
|
||||
<div className="bg-gray-50 px-4 py-2 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-700">Audit Summary</h3>
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
{/* Quick Stats */}
|
||||
<div className="bg-gray-50 px-4 py-3 border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Audit Results
|
||||
</span>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
{severityStats.Pass > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<span className="text-green-700 font-medium">
|
||||
<CheckCircle className="w-3 h-3 text-green-600" />
|
||||
<span className="font-medium text-green-700">
|
||||
{severityStats.Pass}
|
||||
</span>
|
||||
<span className="text-gray-600">Passed</span>
|
||||
</div>
|
||||
)}
|
||||
{severityStats.Warning > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-600" />
|
||||
<span className="text-yellow-700 font-medium">
|
||||
<AlertTriangle className="w-3 h-3 text-yellow-600" />
|
||||
<span className="font-medium text-yellow-700">
|
||||
{severityStats.Warning}
|
||||
</span>
|
||||
<span className="text-gray-600">Warnings</span>
|
||||
</div>
|
||||
)}
|
||||
{severityStats.Error > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<XCircle className="w-4 h-4 text-red-600" />
|
||||
<span className="text-red-700 font-medium">
|
||||
<XCircle className="w-3 h-3 text-red-600" />
|
||||
<span className="font-medium text-red-700">
|
||||
{severityStats.Error}
|
||||
</span>
|
||||
<span className="text-gray-600">Errors</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Summary */}
|
||||
{overall_summary && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="w-4 h-4 text-blue-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-900 text-sm mb-1">
|
||||
Summary
|
||||
</h4>
|
||||
<p className="text-sm text-blue-800">{overall_summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overall Summary */}
|
||||
{overall_summary && (
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-3">
|
||||
Overall Assessment
|
||||
</h3>
|
||||
<p className="text-gray-700 leading-relaxed">{overall_summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section Summaries */}
|
||||
{sections.length > 0 && (
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Section Analysis
|
||||
</h3>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{sections.map((section, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"border rounded-lg p-3",
|
||||
getSeverityColor(section.severity),
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium">{section.section}</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
{getSeverityIcon(section.severity)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
getConfidenceColor(section.confidence),
|
||||
)}
|
||||
>
|
||||
{Math.round(section.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm opacity-90">{section.summary}</p>
|
||||
{/* Critical Issues (if any) */}
|
||||
{criticalFindings.length > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<h4 className="font-medium text-red-900 text-sm mb-2 flex items-center gap-2">
|
||||
<XCircle className="w-4 h-4" />
|
||||
Critical Issues ({criticalFindings.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{criticalFindings.slice(0, 2).map((finding, index) => (
|
||||
<div key={index} className="text-sm text-red-800">
|
||||
<span className="font-medium">{finding.category}:</span>{" "}
|
||||
{finding.message}
|
||||
</div>
|
||||
))}
|
||||
{criticalFindings.length > 2 && (
|
||||
<button
|
||||
onClick={() => setShowAllFindings(!showAllFindings)}
|
||||
className="text-xs text-red-700 hover:text-red-800 font-medium"
|
||||
>
|
||||
{showAllFindings
|
||||
? "Show less"
|
||||
: `+${criticalFindings.length - 2} more issues`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detailed Findings */}
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Detailed Findings ({findings.length})
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{findings.map((finding, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"border rounded-lg p-3",
|
||||
getSeverityColor(finding.severity),
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{getSeverityIcon(finding.severity)}
|
||||
<div>
|
||||
<span className="font-medium">{finding.category}</span>
|
||||
<span className="text-sm text-gray-500 ml-2">
|
||||
#{finding.check_id}
|
||||
{/* Sections Overview */}
|
||||
{sections.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => toggleSection("sections")}
|
||||
className="flex items-center gap-2 w-full text-left p-2 hover:bg-gray-50 rounded-lg"
|
||||
>
|
||||
{expandedSections.has("sections") ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
<span className="font-medium text-sm">
|
||||
Section Analysis ({sections.length})
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expandedSections.has("sections") && (
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
{sections.map((section, index) => (
|
||||
<div key={index} className="border rounded-lg p-3 bg-gray-50">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-medium text-sm">
|
||||
{section.section}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{getSeverityIcon(section.severity)}
|
||||
<span className="text-xs text-gray-600">
|
||||
{Math.round(section.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-700">{section.summary}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-gray-400" />
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
getConfidenceColor(finding.confidence),
|
||||
)}
|
||||
>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Findings */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => toggleSection("findings")}
|
||||
className="flex items-center gap-2 w-full text-left p-2 hover:bg-gray-50 rounded-lg"
|
||||
>
|
||||
{expandedSections.has("findings") ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
<span className="font-medium text-sm">
|
||||
All Findings ({findings.length})
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expandedSections.has("findings") && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{findings.map((finding, index) => (
|
||||
<div key={index} className="border rounded-lg p-3 bg-gray-50">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{getSeverityIcon(finding.severity)}
|
||||
<div>
|
||||
<span className="font-medium text-sm">
|
||||
{finding.category}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 ml-1">
|
||||
#{finding.check_id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600">
|
||||
{Math.round(finding.confidence * 100)}% confidence
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-700 mb-2">
|
||||
{finding.message}
|
||||
</p>
|
||||
|
||||
{finding.mitigation && (
|
||||
<div className="bg-white rounded p-2 border">
|
||||
<span className="text-xs font-medium text-gray-600">
|
||||
Recommended:
|
||||
</span>
|
||||
<p className="text-xs text-gray-700 mt-1">
|
||||
{finding.mitigation}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm mb-2 leading-relaxed">
|
||||
{finding.message}
|
||||
</p>
|
||||
|
||||
{finding.mitigation && (
|
||||
<div className="bg-white bg-opacity-50 rounded p-2 border border-current border-opacity-20">
|
||||
<h5 className="font-medium text-sm mb-1">
|
||||
Recommended Action:
|
||||
</h5>
|
||||
<p className="text-sm">{finding.mitigation}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{notes && (
|
||||
<div className="bg-gray-50 p-3 border-t border-gray-200">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">
|
||||
Additional Notes
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 italic">{notes}</p>
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<FileText className="w-4 h-4 text-gray-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 text-sm mb-1">
|
||||
Notes
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 italic">{notes}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import { Message, MessageContent } from "@/components/ai-elements/message";
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputActionAddAttachments,
|
||||
PromptInputActionMenu,
|
||||
PromptInputActionMenuContent,
|
||||
PromptInputActionMenuTrigger,
|
||||
PromptInputAttachment,
|
||||
PromptInputAttachments,
|
||||
PromptInputBody,
|
||||
PromptInputButton,
|
||||
PromptInputHeader,
|
||||
type PromptInputMessage,
|
||||
PromptInputSelect,
|
||||
PromptInputSelectContent,
|
||||
PromptInputSelectItem,
|
||||
PromptInputSelectTrigger,
|
||||
PromptInputSelectValue,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
PromptInputFooter,
|
||||
PromptInputTools,
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import { Action, Actions } from "@/components/ai-elements/actions";
|
||||
import { Fragment, useState } from "react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { Response } from "@/components/ai-elements/response";
|
||||
import { CopyIcon, GlobeIcon, RefreshCcwIcon } from "lucide-react";
|
||||
import {
|
||||
Source,
|
||||
Sources,
|
||||
SourcesContent,
|
||||
SourcesTrigger,
|
||||
} from "@/components/ai-elements/sources";
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from "@/components/ai-elements/reasoning";
|
||||
import { Loader } from "@/components/ai-elements/loader";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
|
||||
const models = [
|
||||
{
|
||||
name: "GPT 4o",
|
||||
value: "openai/gpt-4o",
|
||||
},
|
||||
{
|
||||
name: "Deepseek R1",
|
||||
value: "deepseek/deepseek-r1",
|
||||
},
|
||||
];
|
||||
|
||||
const ChatBotDemo = () => {
|
||||
const [input, setInput] = useState("");
|
||||
const [model, setModel] = useState<string>(models[0].value);
|
||||
const [webSearch, setWebSearch] = useState(false);
|
||||
const { messages, sendMessage, status, regenerate } = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: "/api/v1/chat",
|
||||
}),
|
||||
});
|
||||
|
||||
const handleSubmit = (message: PromptInputMessage) => {
|
||||
const hasText = Boolean(message.text);
|
||||
const hasAttachments = Boolean(message.files?.length);
|
||||
|
||||
if (!(hasText || hasAttachments)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage(
|
||||
{
|
||||
text: message.text || "Sent with attachments",
|
||||
files: message.files,
|
||||
},
|
||||
{
|
||||
body: {
|
||||
model: model,
|
||||
webSearch: webSearch,
|
||||
},
|
||||
},
|
||||
);
|
||||
setInput("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 relative size-full h-screen">
|
||||
<div className="flex flex-col h-full">
|
||||
<Conversation className="h-full">
|
||||
<ConversationContent>
|
||||
{messages.map((message) => (
|
||||
<div key={message.id}>
|
||||
{message.role === "assistant" &&
|
||||
message.parts.filter((part) => part.type === "source-url")
|
||||
.length > 0 && (
|
||||
<Sources>
|
||||
<SourcesTrigger
|
||||
count={
|
||||
message.parts.filter(
|
||||
(part) => part.type === "source-url",
|
||||
).length
|
||||
}
|
||||
/>
|
||||
{message.parts
|
||||
.filter((part) => part.type === "source-url")
|
||||
.map((part, i) => (
|
||||
<SourcesContent key={`${message.id}-${i}`}>
|
||||
<Source
|
||||
key={`${message.id}-${i}`}
|
||||
href={part.url}
|
||||
title={part.url}
|
||||
/>
|
||||
</SourcesContent>
|
||||
))}
|
||||
</Sources>
|
||||
)}
|
||||
{message.parts.map((part, i) => {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return (
|
||||
<Fragment key={`${message.id}-${i}`}>
|
||||
<Message from={message.role}>
|
||||
<MessageContent>
|
||||
<Response>{part.text}</Response>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
{message.role === "assistant" &&
|
||||
i === messages.length - 1 && (
|
||||
<Actions className="mt-2">
|
||||
<Action
|
||||
onClick={() => regenerate()}
|
||||
label="Retry"
|
||||
>
|
||||
<RefreshCcwIcon className="size-3" />
|
||||
</Action>
|
||||
<Action
|
||||
onClick={() =>
|
||||
navigator.clipboard.writeText(part.text)
|
||||
}
|
||||
label="Copy"
|
||||
>
|
||||
<CopyIcon className="size-3" />
|
||||
</Action>
|
||||
</Actions>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
case "reasoning":
|
||||
return (
|
||||
<Reasoning
|
||||
key={`${message.id}-${i}`}
|
||||
className="w-full"
|
||||
isStreaming={
|
||||
status === "streaming" &&
|
||||
i === message.parts.length - 1 &&
|
||||
message.id === messages.at(-1)?.id
|
||||
}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{part.text}</ReasoningContent>
|
||||
</Reasoning>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{status === "submitted" && <Loader />}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
|
||||
<PromptInput
|
||||
onSubmit={handleSubmit}
|
||||
className="mt-4"
|
||||
globalDrop
|
||||
multiple
|
||||
>
|
||||
<PromptInputHeader>
|
||||
<PromptInputAttachments>
|
||||
{(attachment) => <PromptInputAttachment data={attachment} />}
|
||||
</PromptInputAttachments>
|
||||
</PromptInputHeader>
|
||||
<PromptInputBody>
|
||||
<PromptInputTextarea
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
value={input}
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter>
|
||||
<PromptInputTools>
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuTrigger />
|
||||
<PromptInputActionMenuContent>
|
||||
<PromptInputActionAddAttachments />
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
<PromptInputButton
|
||||
variant={webSearch ? "default" : "ghost"}
|
||||
onClick={() => setWebSearch(!webSearch)}
|
||||
>
|
||||
<GlobeIcon size={16} />
|
||||
<span>Search</span>
|
||||
</PromptInputButton>
|
||||
<PromptInputSelect
|
||||
onValueChange={(value) => {
|
||||
setModel(value);
|
||||
}}
|
||||
value={model}
|
||||
>
|
||||
<PromptInputSelectTrigger>
|
||||
<PromptInputSelectValue />
|
||||
</PromptInputSelectTrigger>
|
||||
<PromptInputSelectContent>
|
||||
{models.map((model) => (
|
||||
<PromptInputSelectItem
|
||||
key={model.value}
|
||||
value={model.value}
|
||||
>
|
||||
{model.name}
|
||||
</PromptInputSelectItem>
|
||||
))}
|
||||
</PromptInputSelectContent>
|
||||
</PromptInputSelect>
|
||||
</PromptInputTools>
|
||||
<PromptInputSubmit disabled={!input && !status} status={status} />
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatBotDemo;
|
||||
@@ -1,8 +1,3 @@
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import { Message, MessageContent } from "@/components/ai-elements/message";
|
||||
import {
|
||||
PromptInput,
|
||||
@@ -31,8 +26,10 @@ import {
|
||||
Bot,
|
||||
AlertCircle,
|
||||
PaperclipIcon,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { AuditReport } from "./AuditReport";
|
||||
import { WebSearchResults } from "./WebSearchResults";
|
||||
import { Loader } from "@/components/ai-elements/loader";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
|
||||
@@ -53,6 +50,9 @@ export function ChatTab({ selectedTema }: ChatTabProps) {
|
||||
} = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: "/api/v1/agent/chat",
|
||||
headers: {
|
||||
tema: selectedTema || "",
|
||||
},
|
||||
}),
|
||||
onError: (error) => {
|
||||
setError(`Error en el chat: ${error.message}`);
|
||||
@@ -103,23 +103,6 @@ export function ChatTab({ selectedTema }: ChatTabProps) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[638px] max-h-[638px]">
|
||||
{/* Chat Header */}
|
||||
<div className="border-b border-gray-200 px-6 py-4 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<MessageCircle className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Chat con {selectedTema}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Haz preguntas sobre los documentos de este dataroom
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Content */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="max-w-4xl mx-auto w-full space-y-6 p-6">
|
||||
@@ -159,30 +142,60 @@ export function ChatTab({ selectedTema }: ChatTabProps) {
|
||||
case "text":
|
||||
return (
|
||||
<Fragment key={`${message.id}-${i}`}>
|
||||
<Message from={message.role} className="max-w-none">
|
||||
<MessageContent>
|
||||
<Response>{part.text}</Response>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
{message.role === "user" ? (
|
||||
<div className="flex items-start gap-3 justify-end">
|
||||
<div className="flex-1">
|
||||
<Message
|
||||
from={message.role}
|
||||
className="max-w-none"
|
||||
>
|
||||
<MessageContent>
|
||||
<Response>{part.text}</Response>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</div>
|
||||
<div className="p-2 rounded-full flex-shrink-0 mt-1 bg-gray-100">
|
||||
<User className="w-4 h-4 text-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-full flex-shrink-0 mt-1 bg-blue-100">
|
||||
<Bot className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Message
|
||||
from={message.role}
|
||||
className="max-w-none"
|
||||
>
|
||||
<MessageContent>
|
||||
<Response>{part.text}</Response>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{message.role === "assistant" &&
|
||||
i === message.parts.length - 1 && (
|
||||
<Actions className="mt-2">
|
||||
<Action
|
||||
onClick={() => regenerate()}
|
||||
label="Regenerar"
|
||||
disabled={status === "streaming"}
|
||||
>
|
||||
<RefreshCcwIcon className="size-3" />
|
||||
</Action>
|
||||
<Action
|
||||
onClick={() =>
|
||||
navigator.clipboard.writeText(part.text)
|
||||
}
|
||||
label="Copiar"
|
||||
>
|
||||
<CopyIcon className="size-3" />
|
||||
</Action>
|
||||
</Actions>
|
||||
<div className="ml-12">
|
||||
<Actions className="mt-2">
|
||||
<Action
|
||||
onClick={() => regenerate()}
|
||||
label="Regenerar"
|
||||
disabled={status === "streaming"}
|
||||
>
|
||||
<RefreshCcwIcon className="size-3" />
|
||||
</Action>
|
||||
<Action
|
||||
onClick={() =>
|
||||
navigator.clipboard.writeText(part.text)
|
||||
}
|
||||
label="Copiar"
|
||||
>
|
||||
<CopyIcon className="size-3" />
|
||||
</Action>
|
||||
</Actions>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
@@ -231,6 +244,51 @@ export function ChatTab({ selectedTema }: ChatTabProps) {
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
case "tool-search_web_information":
|
||||
switch (part.state) {
|
||||
case "input-available":
|
||||
return (
|
||||
<div
|
||||
key={`${message.id}-${i}`}
|
||||
className="flex items-center gap-2 p-4 bg-green-50 rounded-lg border border-green-200"
|
||||
>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600"></div>
|
||||
<span className="text-sm text-green-700">
|
||||
Searching the web...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
case "output-available":
|
||||
return (
|
||||
<div
|
||||
key={`${message.id}-${i}`}
|
||||
className="mt-4 w-full"
|
||||
>
|
||||
<div className="max-w-full overflow-hidden">
|
||||
<WebSearchResults data={part.output} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "output-error":
|
||||
return (
|
||||
<div
|
||||
key={`${message.id}-${i}`}
|
||||
className="p-4 bg-red-50 border border-red-200 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-600" />
|
||||
<span className="text-sm font-medium text-red-800">
|
||||
Error searching the web
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-red-600 mt-1">
|
||||
{part.errorText}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -281,47 +281,6 @@ export function DashboardTab({ selectedTema }: DashboardTabProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collection Details */}
|
||||
{dataroomInfo.collection_info && (
|
||||
<div className="mt-8">
|
||||
<h4 className="text-md font-semibold text-gray-900 mb-4">
|
||||
Detalles de la Colección
|
||||
</h4>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
Total Vectores
|
||||
</p>
|
||||
<p className="text-lg font-bold text-gray-900">
|
||||
{dataroomInfo.collection_info.vectors_count}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
Vectores Indexados
|
||||
</p>
|
||||
<p className="text-lg font-bold text-gray-900">
|
||||
{dataroomInfo.collection_info.indexed_vectors_count}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Puntos</p>
|
||||
<p className="text-lg font-bold text-gray-900">
|
||||
{dataroomInfo.collection_info.points_count}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Segmentos</p>
|
||||
<p className="text-lg font-bold text-gray-900">
|
||||
{dataroomInfo.collection_info.segments_count}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/fileStore";
|
||||
import { api } from "@/services/api";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Expand, Minimize2 } from "lucide-react";
|
||||
import { FilesTab } from "./FilesTab";
|
||||
import { DashboardTab } from "./DashboardTab";
|
||||
import { ChatTab } from "./ChatTab";
|
||||
@@ -11,15 +18,69 @@ interface DataroomViewProps {
|
||||
}
|
||||
|
||||
export function DataroomView({ onProcessingChange }: DataroomViewProps = {}) {
|
||||
const { selectedTema, files } = useFileStore();
|
||||
const { selectedTema } = useFileStore();
|
||||
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [fullscreenTab, setFullscreenTab] = useState<string | null>(null);
|
||||
const [currentTab, setCurrentTab] = useState("overview");
|
||||
|
||||
const handleProcessingChange = (isProcessing: boolean) => {
|
||||
setProcessing(isProcessing);
|
||||
onProcessingChange?.(isProcessing);
|
||||
};
|
||||
|
||||
const openFullscreen = (tabValue: string) => {
|
||||
setFullscreenTab(tabValue);
|
||||
};
|
||||
|
||||
const closeFullscreen = () => {
|
||||
setFullscreenTab(null);
|
||||
};
|
||||
|
||||
const renderTabContent = (tabValue: string, isFullscreen = false) => {
|
||||
const className = isFullscreen ? "h-[calc(100vh-8rem)] flex flex-col" : "";
|
||||
|
||||
switch (tabValue) {
|
||||
case "overview":
|
||||
return (
|
||||
<div className={className}>
|
||||
<DashboardTab selectedTema={selectedTema} />
|
||||
</div>
|
||||
);
|
||||
case "files":
|
||||
return (
|
||||
<div className={className}>
|
||||
<FilesTab
|
||||
selectedTema={selectedTema}
|
||||
processing={processing}
|
||||
onProcessingChange={handleProcessingChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "chat":
|
||||
return (
|
||||
<div className={className}>
|
||||
<ChatTab selectedTema={selectedTema} />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getTabTitle = (tabValue: string) => {
|
||||
switch (tabValue) {
|
||||
case "overview":
|
||||
return "Overview";
|
||||
case "files":
|
||||
return "Files";
|
||||
case "chat":
|
||||
return "Chat";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white">
|
||||
<div className="border-b border-gray-200 px-6 py-4">
|
||||
@@ -39,46 +100,86 @@ export function DataroomView({ onProcessingChange }: DataroomViewProps = {}) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="files" className="flex flex-col flex-1">
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
onValueChange={setCurrentTab}
|
||||
className="flex flex-col flex-1"
|
||||
>
|
||||
<div className="border-b border-gray-200 px-6 py-2">
|
||||
<TabsList className="flex h-10 w-full items-center gap-2 bg-transparent p-0 justify-start">
|
||||
<TabsTrigger
|
||||
value="overview"
|
||||
className="rounded-md px-4 py-2 text-sm font-medium text-gray-600 transition data-[state=active]:bg-gray-900 data-[state=active]:text-white data-[state=active]:shadow"
|
||||
<TabsList className="flex h-10 w-full items-center gap-2 bg-transparent p-0 justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<TabsTrigger
|
||||
value="overview"
|
||||
className="rounded-md px-4 py-2 text-sm font-medium text-gray-600 transition data-[state=active]:bg-gray-900 data-[state=active]:text-white data-[state=active]:shadow"
|
||||
>
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="files"
|
||||
className="rounded-md px-4 py-2 text-sm font-medium text-gray-600 transition data-[state=active]:bg-gray-900 data-[state=active]:text-white data-[state=active]:shadow"
|
||||
>
|
||||
Files
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="chat"
|
||||
className="rounded-md px-4 py-2 text-sm font-medium text-gray-600 transition data-[state=active]:bg-gray-900 data-[state=active]:text-white data-[state=active]:shadow"
|
||||
>
|
||||
Chat
|
||||
</TabsTrigger>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openFullscreen(currentTab)}
|
||||
className="ml-auto"
|
||||
>
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="files"
|
||||
className="rounded-md px-4 py-2 text-sm font-medium text-gray-600 transition data-[state=active]:bg-gray-900 data-[state=active]:text-white data-[state=active]:shadow"
|
||||
>
|
||||
Files
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="chat"
|
||||
className="rounded-md px-4 py-2 text-sm font-medium text-gray-600 transition data-[state=active]:bg-gray-900 data-[state=active]:text-white data-[state=active]:shadow"
|
||||
>
|
||||
Chat
|
||||
</TabsTrigger>
|
||||
<Expand className="h-4 w-4" />
|
||||
<span className="sr-only">Open fullscreen</span>
|
||||
</Button>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="overview" className="mt-0 flex-1">
|
||||
<DashboardTab selectedTema={selectedTema} />
|
||||
{renderTabContent("overview")}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="files" className="mt-0 flex flex-1 flex-col">
|
||||
<FilesTab
|
||||
selectedTema={selectedTema}
|
||||
processing={processing}
|
||||
onProcessingChange={handleProcessingChange}
|
||||
/>
|
||||
{renderTabContent("files")}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="chat" className="mt-0 flex-1">
|
||||
<ChatTab selectedTema={selectedTema} />
|
||||
{renderTabContent("chat")}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Dialog
|
||||
open={fullscreenTab !== null}
|
||||
onOpenChange={(open: boolean) => !open && closeFullscreen()}
|
||||
>
|
||||
<DialogContent className="max-w-[100vw] max-h-[100vh] w-[100vw] h-[100vh] m-0 rounded-none [&>button]:hidden">
|
||||
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
{selectedTema
|
||||
? `${getTabTitle(fullscreenTab || "")} - ${selectedTema}`
|
||||
: getTabTitle(fullscreenTab || "")}
|
||||
</DialogTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={closeFullscreen}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
<span className="sr-only">Exit fullscreen</span>
|
||||
</Button>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{fullscreenTab && renderTabContent(fullscreenTab, true)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
206
frontend/src/components/WebSearchResults.tsx
Normal file
206
frontend/src/components/WebSearchResults.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Globe,
|
||||
ExternalLink,
|
||||
Search,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
content: string;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
interface WebSearchData {
|
||||
query: string;
|
||||
results: SearchResult[];
|
||||
summary: string;
|
||||
total_results: number;
|
||||
}
|
||||
|
||||
interface WebSearchResultsProps {
|
||||
data: WebSearchData;
|
||||
}
|
||||
|
||||
const getScoreColor = (score?: number) => {
|
||||
if (!score) return "text-gray-500";
|
||||
if (score >= 0.8) return "text-green-600";
|
||||
if (score >= 0.6) return "text-yellow-600";
|
||||
return "text-gray-500";
|
||||
};
|
||||
|
||||
const getScoreStars = (score?: number) => {
|
||||
if (!score) return 0;
|
||||
return Math.round(score * 5);
|
||||
};
|
||||
|
||||
const truncateContent = (content: string, maxLength: number = 200) => {
|
||||
if (content.length <= maxLength) return content;
|
||||
return content.slice(0, maxLength) + "...";
|
||||
};
|
||||
|
||||
export const WebSearchResults: React.FC<WebSearchResultsProps> = ({ data }) => {
|
||||
const [expandedResults, setExpandedResults] = useState<Set<number>>(new Set());
|
||||
const [showAllResults, setShowAllResults] = useState(false);
|
||||
|
||||
const { query, results, summary, total_results } = data;
|
||||
|
||||
const toggleResult = (index: number) => {
|
||||
const newExpanded = new Set(expandedResults);
|
||||
if (newExpanded.has(index)) {
|
||||
newExpanded.delete(index);
|
||||
} else {
|
||||
newExpanded.add(index);
|
||||
}
|
||||
setExpandedResults(newExpanded);
|
||||
};
|
||||
|
||||
const visibleResults = showAllResults ? results : results.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-green-50 to-emerald-50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="w-6 h-6 text-green-600" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Web Search Results</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<Search className="w-3 h-3" />
|
||||
<span>"{query}"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-gray-900">{results.length}</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
of {total_results} results
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Summary */}
|
||||
{summary && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-900 text-sm mb-1">
|
||||
Summary
|
||||
</h4>
|
||||
<p className="text-sm text-blue-800">{summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Results */}
|
||||
<div className="space-y-3">
|
||||
{visibleResults.map((result, index) => {
|
||||
const isExpanded = expandedResults.has(index);
|
||||
const stars = getScoreStars(result.score);
|
||||
|
||||
return (
|
||||
<div key={index} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="p-3">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-gray-900 text-sm mb-1 line-clamp-2">
|
||||
{result.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1 truncate"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 flex-shrink-0" />
|
||||
{new URL(result.url).hostname}
|
||||
</a>
|
||||
{result.score && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={cn(
|
||||
"w-3 h-3",
|
||||
i < stars
|
||||
? "text-yellow-400 fill-current"
|
||||
: "text-gray-300"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className={cn("text-xs font-medium", getScoreColor(result.score))}>
|
||||
{Math.round((result.score || 0) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-700">
|
||||
{isExpanded ? result.content : truncateContent(result.content)}
|
||||
</div>
|
||||
|
||||
{result.content.length > 200 && (
|
||||
<button
|
||||
onClick={() => toggleResult(index)}
|
||||
className="mt-2 flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
Show less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
Read more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Show More/Less Button */}
|
||||
{results.length > 3 && (
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={() => setShowAllResults(!showAllResults)}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 font-medium px-4 py-2 rounded-lg hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
{showAllResults
|
||||
? "Show fewer results"
|
||||
: `Show ${results.length - 3} more results`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Results */}
|
||||
{results.length === 0 && (
|
||||
<div className="text-center py-6">
|
||||
<Search className="w-8 h-8 text-gray-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-600">No results found for "{query}"</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user