DocumentSelector in frontend to select documents to do research on.
This commit is contained in:
parent
39cbec1b88
commit
9fd33a2d83
3 changed files with 370 additions and 3 deletions
|
|
@ -92,8 +92,8 @@ async def stream_research_agent(request: Request) -> StreamingResponse:
|
|||
effective_db_path = Path(effective_db_path)
|
||||
client = get_client(effective_db_path)
|
||||
|
||||
# Build search filter from document IDs if provided
|
||||
document_ids = input_data.state.get("documentFilter")
|
||||
# Build search filter from document IDs (empty list = search all)
|
||||
document_ids = input_data.state.get("documentFilter") or []
|
||||
search_filter = None
|
||||
if document_ids:
|
||||
ids_str = ", ".join(f"'{id}'" for id in document_ids)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { CopilotKit, useCoAgent } from "@copilotkit/react-core";
|
||||
import { CopilotChat } from "@copilotkit/react-ui";
|
||||
import "@copilotkit/react-ui/styles.css";
|
||||
import DocumentSelector from "./DocumentSelector";
|
||||
import StateDisplay from "./StateDisplay";
|
||||
|
||||
interface InsightRecord {
|
||||
|
|
@ -82,10 +83,11 @@ interface ResearchState {
|
|||
result?: ResearchReport;
|
||||
current_activity?: string;
|
||||
current_activity_message?: string;
|
||||
documentFilter?: string[];
|
||||
}
|
||||
|
||||
function AgentContent() {
|
||||
const { state } = useCoAgent<ResearchState>({
|
||||
const { state, setState, running } = useCoAgent<ResearchState>({
|
||||
name: "research_agent",
|
||||
initialState: {
|
||||
context: {
|
||||
|
|
@ -101,9 +103,14 @@ function AgentContent() {
|
|||
max_concurrency: 1,
|
||||
last_eval: null,
|
||||
last_analysis: null,
|
||||
documentFilter: [],
|
||||
},
|
||||
});
|
||||
|
||||
const handleDocumentFilterChange = (ids: string[]) => {
|
||||
setState({ ...state, documentFilter: ids });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
|
|
@ -163,6 +170,14 @@ function AgentContent() {
|
|||
</p>
|
||||
</header>
|
||||
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<DocumentSelector
|
||||
selected={state.documentFilter || []}
|
||||
onChange={handleDocumentFilterChange}
|
||||
disabled={running}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StateDisplay state={state} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
352
examples/ag-ui-research/frontend/components/DocumentSelector.tsx
Normal file
352
examples/ag-ui-research/frontend/components/DocumentSelector.tsx
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
title: string;
|
||||
uri: string;
|
||||
}
|
||||
|
||||
interface DocumentSelectorProps {
|
||||
selected: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export default function DocumentSelector({
|
||||
selected,
|
||||
onChange,
|
||||
disabled,
|
||||
}: DocumentSelectorProps) {
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDocuments = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/api/documents`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch documents");
|
||||
}
|
||||
const data = await response.json();
|
||||
const docs = data.documents || [];
|
||||
setDocuments(docs);
|
||||
// Select all documents by default if none are selected
|
||||
if (selected.length === 0 && docs.length > 0) {
|
||||
onChange(docs.map((d: Document) => d.id));
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDocuments();
|
||||
}, []);
|
||||
|
||||
const filteredDocuments = useMemo(() => {
|
||||
if (!searchQuery.trim()) return documents;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return documents.filter(
|
||||
(doc) =>
|
||||
(doc.title || "").toLowerCase().includes(query) ||
|
||||
(doc.uri || "").toLowerCase().includes(query),
|
||||
);
|
||||
}, [documents, searchQuery]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(id: string) => {
|
||||
if (disabled) return;
|
||||
if (selected.includes(id)) {
|
||||
onChange(selected.filter((s) => s !== id));
|
||||
} else {
|
||||
onChange([...selected, id]);
|
||||
}
|
||||
},
|
||||
[selected, onChange, disabled],
|
||||
);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (disabled) return;
|
||||
const filteredIds = filteredDocuments.map((d) => d.id);
|
||||
const allFilteredSelected = filteredIds.every((id) =>
|
||||
selected.includes(id),
|
||||
);
|
||||
if (allFilteredSelected) {
|
||||
// Deselect all filtered documents
|
||||
onChange(selected.filter((id) => !filteredIds.includes(id)));
|
||||
} else {
|
||||
// Select all filtered documents (add to existing selection)
|
||||
const newSelection = [...new Set([...selected, ...filteredIds])];
|
||||
onChange(newSelection);
|
||||
}
|
||||
}, [selected, filteredDocuments, onChange, disabled]);
|
||||
|
||||
const selectedCount = selected.length;
|
||||
const totalCount = documents.length;
|
||||
const filterActive = selectedCount > 0 && selectedCount < totalCount;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "white",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
|
||||
overflow: "hidden",
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "0.75rem",
|
||||
background: filterActive ? "#ebf8ff" : "#edf2f7",
|
||||
border: filterActive ? "1px solid #90cdf4" : "1px solid #e2e8f0",
|
||||
borderRadius: expanded ? "8px 8px 0 0" : "8px",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: "600",
|
||||
color: filterActive ? "#2b6cb0" : "#2d3748",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Document Filter
|
||||
{filterActive && ` (${selectedCount}/${totalCount})`}
|
||||
{!filterActive && selectedCount === 0 && " (All)"}
|
||||
</span>
|
||||
<span>{expanded ? "▼" : "▶"}</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem",
|
||||
background: "#f7fafc",
|
||||
borderLeft: "1px solid #e2e8f0",
|
||||
borderRight: "1px solid #e2e8f0",
|
||||
borderBottom: "1px solid #e2e8f0",
|
||||
borderRadius: "0 0 8px 8px",
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem",
|
||||
textAlign: "center",
|
||||
color: "#718096",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
Loading documents...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem",
|
||||
background: "#fed7d7",
|
||||
color: "#c53030",
|
||||
borderRadius: "4px",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && documents.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem",
|
||||
textAlign: "center",
|
||||
color: "#718096",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
No documents in database
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && documents.length > 0 && (
|
||||
<>
|
||||
{/* Search Input */}
|
||||
<div style={{ marginBottom: "0.5rem" }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by title or URI..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.5rem 0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
border: "1px solid #e2e8f0",
|
||||
borderRadius: "4px",
|
||||
background: disabled ? "#f7fafc" : "white",
|
||||
color: disabled ? "#a0aec0" : "#2d3748",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Select All / Clear */}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "0.5rem",
|
||||
paddingBottom: "0.5rem",
|
||||
borderBottom: "1px solid #e2e8f0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAll}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
padding: "0.375rem 0.75rem",
|
||||
fontSize: "0.75rem",
|
||||
background: disabled ? "#e2e8f0" : "#4299e1",
|
||||
color: disabled ? "#a0aec0" : "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
{filteredDocuments.every((d) => selected.includes(d.id))
|
||||
? "Clear Visible"
|
||||
: "Select Visible"}
|
||||
</button>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: "0.75rem",
|
||||
fontSize: "0.75rem",
|
||||
color: "#718096",
|
||||
}}
|
||||
>
|
||||
{selectedCount} of {totalCount} selected
|
||||
</span>
|
||||
</div>
|
||||
{searchQuery && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "#718096",
|
||||
}}
|
||||
>
|
||||
Showing {filteredDocuments.length} of {totalCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document List */}
|
||||
<div
|
||||
style={{
|
||||
maxHeight: "200px",
|
||||
overflowY: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{filteredDocuments.map((doc) => {
|
||||
const isSelected = selected.includes(doc.id);
|
||||
return (
|
||||
<label
|
||||
key={doc.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.5rem",
|
||||
background: isSelected ? "#ebf8ff" : "white",
|
||||
border: isSelected
|
||||
? "1px solid #90cdf4"
|
||||
: "1px solid #e2e8f0",
|
||||
borderRadius: "4px",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleToggle(doc.id)}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
width: "1rem",
|
||||
height: "1rem",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: isSelected ? "600" : "400",
|
||||
color: "#2d3748",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{doc.title || "Untitled"}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
color: "#718096",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{doc.uri}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{disabled && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
padding: "0.5rem",
|
||||
background: "#fef5e7",
|
||||
border: "1px solid #f6ad55",
|
||||
borderRadius: "4px",
|
||||
fontSize: "0.75rem",
|
||||
color: "#744210",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Filter locked during research
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue