haiku.rag/examples/ag-ui-research/frontend/components/Agent.tsx
2025-12-18 11:48:34 +02:00

365 lines
9.5 KiB
TypeScript

"use client";
import { CopilotKit, useCoAgent } from "@copilotkit/react-core";
import { CopilotChat } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";
import { useState, useEffect } from "react";
import DocumentSelector from "./DocumentSelector";
import StateDisplay from "./StateDisplay";
interface Citation {
document_id: string;
chunk_id: string;
document_uri: string;
document_title?: string;
page_numbers: number[];
headings?: string[];
content: string;
}
interface SearchAnswer {
query: string;
answer: string;
confidence: number;
cited_chunks: string[];
citations: Citation[];
}
interface ResearchContext {
original_question: string;
sub_questions: string[];
qa_responses: SearchAnswer[];
}
interface EvaluationResult {
new_questions: string[];
confidence_score: number;
is_sufficient: boolean;
reasoning: string;
}
interface ResearchReport {
title: string;
executive_summary: string;
main_findings: string[];
conclusions: string[];
limitations: string[];
recommendations: string[];
sources_summary: string;
}
interface ResearchState {
context: ResearchContext;
iterations: number;
max_iterations: number;
confidence_threshold: number;
max_concurrency: number;
last_eval: EvaluationResult | null;
result?: ResearchReport;
current_activity?: string;
current_activity_message?: string;
documentFilter?: string[];
awaiting_decision?: boolean;
}
function AgentContent() {
const { state, setState, running } = useCoAgent<ResearchState>({
name: "research_agent",
initialState: {
context: {
original_question: "",
sub_questions: [],
qa_responses: [],
},
iterations: 0,
max_iterations: 3,
confidence_threshold: 0.8,
max_concurrency: 1,
last_eval: null,
documentFilter: [],
awaiting_decision: false,
},
});
const [editableQuestions, setEditableQuestions] = useState<string[]>([]);
const [newQuestion, setNewQuestion] = useState("");
const [submitting, setSubmitting] = useState(false);
// Sync editable questions when state changes
useEffect(() => {
if (state.awaiting_decision && state.context.sub_questions) {
setEditableQuestions([...state.context.sub_questions]);
}
}, [state.awaiting_decision, state.context.sub_questions]);
const handleDocumentFilterChange = (ids: string[]) => {
setState({ ...state, documentFilter: ids });
};
const handleRemoveQuestion = (index: number) => {
setEditableQuestions(editableQuestions.filter((_, i) => i !== index));
};
const handleAddQuestion = () => {
if (newQuestion.trim()) {
setEditableQuestions([...editableQuestions, newQuestion.trim()]);
setNewQuestion("");
}
};
const handleDecision = async (action: "search" | "synthesize") => {
setSubmitting(true);
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/v1/research/decide`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
thread_id: state.context.original_question, // Use as identifier
action,
questions: editableQuestions,
}),
}
);
if (response.ok) {
setState({ ...state, awaiting_decision: false });
}
} catch (error) {
console.error("Failed to send decision:", error);
} finally {
setSubmitting(false);
}
};
return (
<>
<style>{`
.chat-container {
width: 50%;
height: 100vh;
border-right: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
}
.chat-container > * {
flex: 1;
min-height: 0;
}
`}</style>
<div style={{ display: "flex", height: "100vh" }}>
{/* Chat on the left */}
<div className="chat-container">
<CopilotChat
labels={{
title: "Research Assistant",
initial:
"Hello! I can help you conduct deep research on complex questions using the haiku.rag knowledge base. Ask me anything!",
}}
/>
</div>
{/* State display on the right */}
<div
style={{
width: "50%",
height: "100vh",
overflow: "auto",
background: "#f7fafc",
}}
>
<div style={{ padding: "2rem" }}>
<header style={{ marginBottom: "2rem" }}>
<h1
style={{
fontSize: "2rem",
fontWeight: "bold",
marginBottom: "0.5rem",
color: "#1a202c",
}}
>
Research State
</h1>
<p
style={{
fontSize: "0.875rem",
color: "#4a5568",
lineHeight: "1.6",
}}
>
Live updates from the research agent
</p>
</header>
{/* Document filter - hidden when research is running */}
{!running && (
<div style={{ marginBottom: "1rem" }}>
<DocumentSelector
selected={state.documentFilter || []}
onChange={handleDocumentFilterChange}
/>
</div>
)}
{/* Decision UI when awaiting human input - hidden when report exists, submitting, or not running */}
{state.awaiting_decision && !submitting && !state.result && running && (
<div
style={{
marginBottom: "1rem",
background: "#f0f9ff",
border: "2px solid #0ea5e9",
borderRadius: "8px",
padding: "1rem",
}}
>
<div
style={{
fontWeight: "bold",
color: "#0369a1",
marginBottom: "0.75rem",
fontSize: "1rem",
}}
>
Research Decision Point
</div>
<div style={{ fontSize: "0.85rem", color: "#64748b", marginBottom: "0.75rem" }}>
{state.context.qa_responses?.length || 0} answers collected | Iteration {state.iterations || 0}
</div>
{/* Questions list */}
<div style={{ marginBottom: "0.75rem" }}>
<div style={{ fontSize: "0.8rem", color: "#475569", marginBottom: "0.5rem" }}>
Pending Questions ({editableQuestions.length}):
</div>
{editableQuestions.map((q, idx) => (
<div
key={idx}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.375rem 0.5rem",
background: "white",
borderRadius: "4px",
marginBottom: "0.25rem",
fontSize: "0.85rem",
}}
>
<span style={{ flex: 1 }}>{q}</span>
<button
type="button"
onClick={() => handleRemoveQuestion(idx)}
disabled={submitting}
style={{
background: "#ef4444",
color: "white",
border: "none",
borderRadius: "4px",
padding: "0.25rem 0.5rem",
cursor: submitting ? "not-allowed" : "pointer",
fontSize: "0.75rem",
}}
>
Remove
</button>
</div>
))}
</div>
{/* Add question input */}
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}>
<input
type="text"
value={newQuestion}
onChange={(e) => setNewQuestion(e.target.value)}
placeholder="Add a new question..."
disabled={submitting}
style={{
flex: 1,
padding: "0.5rem",
border: "1px solid #cbd5e1",
borderRadius: "4px",
fontSize: "0.85rem",
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleAddQuestion();
}
}}
/>
<button
type="button"
onClick={handleAddQuestion}
disabled={submitting}
style={{
background: "#22c55e",
color: "white",
border: "none",
borderRadius: "4px",
padding: "0.5rem 1rem",
cursor: submitting ? "not-allowed" : "pointer",
fontSize: "0.85rem",
}}
>
Add
</button>
</div>
{/* Action buttons */}
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
type="button"
onClick={() => handleDecision("search")}
disabled={editableQuestions.length === 0 || submitting}
style={{
flex: 1,
background: editableQuestions.length === 0 || submitting ? "#94a3b8" : "#0ea5e9",
color: "white",
border: "none",
borderRadius: "4px",
padding: "0.75rem",
cursor: editableQuestions.length === 0 || submitting ? "not-allowed" : "pointer",
fontWeight: "bold",
fontSize: "0.9rem",
}}
>
{submitting ? "Submitting..." : `Search (${editableQuestions.length})`}
</button>
<button
type="button"
onClick={() => handleDecision("synthesize")}
disabled={submitting || (state.context.qa_responses?.length || 0) === 0}
style={{
flex: 1,
background: submitting || (state.context.qa_responses?.length || 0) === 0 ? "#94a3b8" : "#8b5cf6",
color: "white",
border: "none",
borderRadius: "4px",
padding: "0.75rem",
cursor: submitting || (state.context.qa_responses?.length || 0) === 0 ? "not-allowed" : "pointer",
fontWeight: "bold",
fontSize: "0.9rem",
}}
>
Generate Report
</button>
</div>
</div>
)}
<StateDisplay state={state} />
</div>
</div>
</div>
</>
);
}
export default function Agent() {
return (
<CopilotKit runtimeUrl="/api/copilotkit" agent="research_agent">
<AgentContent />
</CopilotKit>
);
}