Use proper ag-ui tool calls in ag-ui-example. Fuck copilotkit

This commit is contained in:
Yiorgis Gozadinos 2025-12-17 17:26:45 +02:00
parent 72a39d07a2
commit 9c766846c0
No known key found for this signature in database
10 changed files with 395 additions and 300 deletions

View file

@ -12,8 +12,10 @@
- New `human_decide` graph node emits AG-UI tool calls (`TOOL_CALL_START/ARGS/END`) for frontend integration - New `human_decide` graph node emits AG-UI tool calls (`TOOL_CALL_START/ARGS/END`) for frontend integration
- New `emit_tool_call_start()`, `emit_tool_call_args()`, `emit_tool_call_end()` AG-UI event helpers - New `emit_tool_call_start()`, `emit_tool_call_args()`, `emit_tool_call_end()` AG-UI event helpers
- New `AGUIEmitter.emit()` method for direct event emission - New `AGUIEmitter.emit()` method for direct event emission
- **AG-UI Research Example**: Updated with interactive decision UI - **AG-UI Research Example**: Human-in-the-loop research with client-side tool calling
- Decision panel with question editing (add/remove) at each decision point - Frontend handles `human_decision` tool calls via AG-UI `TOOL_CALL_*` events
- Tool results sent directly to backend `/v1/research/stream` endpoint
- Backend queues decisions and continues the research graph
- **HotpotQA Evaluation**: Added HotpotQA dataset adapter for multi-hop QA benchmarks - **HotpotQA Evaluation**: Added HotpotQA dataset adapter for multi-hop QA benchmarks
- Extracts unique documents from validation set context paragraphs - Extracts unique documents from validation set context paragraphs
- Uses MAP for retrieval evaluation (multiple supporting documents per question) - Uses MAP for retrieval evaluation (multiple supporting documents per question)

View file

@ -219,8 +219,9 @@ In interactive mode, you can:
- Execute searches and review collected answers - Execute searches and review collected answers
- Continue researching or synthesize when ready - Continue researching or synthesize when ready
For a web-based interactive experience with visual decision UI, see the [AG-UI Research Example](https://github.com/ggozad/haiku.rag/tree/main/examples/ag-ui-research). The web interface provides: For a web-based interactive experience, see the [AG-UI Research Example](https://github.com/ggozad/haiku.rag/tree/main/examples/ag-ui-research). The example demonstrates AG-UI client-side tool calling:
- Question editing panel to add/remove sub-questions at decision points - Frontend handles `human_decision` tool calls via AG-UI `TOOL_CALL_*` events
- Search and Generate Report buttons for controlling research flow - Decision UI rendered inline in the chat at each decision point
- Live state display showing answers, confidence, and progress - Question editing (add/remove) and action buttons (Search, Generate Report)
- Tool results sent directly to the backend endpoint which queues decisions and continues the graph

View file

@ -6,7 +6,7 @@ import logfire
from pydantic_ai import Agent, RunContext from pydantic_ai import Agent, RunContext
from haiku.rag.config import AppConfig, Config from haiku.rag.config import AppConfig, Config
from haiku.rag.graph.common import get_model from haiku.rag.utils import get_model
from .context import load_message_history, save_message_history from .context import load_message_history, save_message_history
from .models import A2AConfig, AgentDependencies, SearchResult from .models import A2AConfig, AgentDependencies, SearchResult

View file

@ -93,7 +93,7 @@ Research assistant powered by [haiku.rag](https://ggozad.github.io/haiku.rag/),
### Agent + Graph Pattern ### Agent + Graph Pattern
This example demonstrates the **agent+graph** architecture pattern: This example demonstrates the **agent+graph** architecture with AG-UI client-side tool calls:
1. **Conversational Agent** (`agent.py`): 1. **Conversational Agent** (`agent.py`):
- Pydantic AI agent handles user conversations - Pydantic AI agent handles user conversations
@ -102,13 +102,14 @@ This example demonstrates the **agent+graph** architecture pattern:
2. **Interactive Research Graph** (haiku.rag): 2. **Interactive Research Graph** (haiku.rag):
- Multi-step research workflow invoked by the agent's tool - Multi-step research workflow invoked by the agent's tool
- Pauses at decision points waiting for human input via async queue - At decision points, emits AG-UI `TOOL_CALL_START/ARGS/END` events for `human_decision`
- Emits AG-UI events for real-time progress tracking - Waits for tool result via async queue before continuing
3. **Decision Endpoint** (`main.py`): 3. **Client-Side Tool Handling** (AG-UI pattern):
- `/v1/research/decide` receives human decisions from frontend - Frontend listens for `human_decision` tool calls via AG-UI events
- Forwards decisions to the waiting graph via `HumanDecision` queue - Renders decision UI inline in chat when tool call is received
- Supports actions: `search`, `synthesize`, `modify_questions` - User decision sent directly to backend `/v1/research/stream` endpoint
- Backend extracts tool result from messages and routes to waiting graph via async queue
4. **Shared Event Stream**: 4. **Shared Event Stream**:
- `AGUIEmitter` is shared between agent and graph - `AGUIEmitter` is shared between agent and graph
@ -120,14 +121,14 @@ This example demonstrates the **agent+graph** architecture pattern:
- **Backend** (Python): - **Backend** (Python):
- Uses published `ghcr.io/ggozad/haiku.rag:latest` Docker image as base - Uses published `ghcr.io/ggozad/haiku.rag:latest` Docker image as base
- `agent.py`: Pydantic AI agent with `run_research` tool, manages `ActiveResearch` registry - `agent.py`: Pydantic AI agent with `run_research` tool, manages `ActiveResearch` registry
- `main.py`: Custom AG-UI streaming endpoint, decision endpoint for human input - `main.py`: Custom AG-UI streaming endpoint, extracts tool results from messages
- Real-time event forwarding from emitter to SSE stream - Real-time event forwarding from emitter to SSE stream
- **Frontend** (Next.js/React): - **Frontend** (Next.js/React):
- CopilotKit for AG-UI protocol integration - AG-UI protocol integration for real-time streaming
- Handles `human_decision` tool calls with inline decision UI
- Split-pane UI: chat on left, live research state on right - Split-pane UI: chat on left, live research state on right
- Decision UI: question editor with add/remove, search and generate report buttons - Tool results sent directly to backend endpoint
- Real-time state synchronization via Server-Sent Events (SSE)
## Configuration ## Configuration

View file

@ -50,6 +50,7 @@ class AgentDeps:
agui_emitter: "AGUIEmitter[ResearchState, ResearchReport] | None" = None agui_emitter: "AGUIEmitter[ResearchState, ResearchReport] | None" = None
search_filter: str | None = None search_filter: str | None = None
thread_id: str | None = None thread_id: str | None = None
research_result: "ResearchReport | None" = None
model = get_model(Config.research.model, Config) model = get_model(Config.research.model, Config)
@ -119,6 +120,8 @@ async def run_research(ctx: RunContext[AgentDeps], question: str) -> str:
if ctx.deps.agui_emitter: if ctx.deps.agui_emitter:
ctx.deps.agui_emitter.log("Research complete!") ctx.deps.agui_emitter.log("Research complete!")
# Store result for main.py to emit RUN_FINISHED after agent completes
ctx.deps.research_result = result
return f"""Research completed successfully! return f"""Research completed successfully!

View file

@ -63,11 +63,54 @@ def get_client(effective_db_path: Path) -> HaikuRAG:
return _client_cache[path_key] return _client_cache[path_key]
def extract_tool_result(messages: list[dict]) -> dict | None:
"""Extract human_decision tool result from messages if present."""
for msg in reversed(messages):
# Check for tool result message (CopilotKit sends role="tool")
if msg.get("role") == "tool":
content = msg.get("content")
# Content may be a string (JSON) or dict
if isinstance(content, str):
try:
content = json.loads(content)
except json.JSONDecodeError:
continue
if isinstance(content, dict) and "action" in content:
return content
return None
async def stream_research_agent(request: Request) -> StreamingResponse: async def stream_research_agent(request: Request) -> StreamingResponse:
"""Agent streaming endpoint with research graph integration.""" """Agent streaming endpoint with research graph integration."""
body = await request.json() body = await request.json()
logger.info(f"Received request body keys: {list(body.keys())}")
if "tools" in body:
logger.info(f"Frontend tools received: {body['tools']}")
input_data = RunAgentInput(**body) input_data = RunAgentInput(**body)
thread_id = input_data.thread_id
active_research = _active_research.get(thread_id) if thread_id else None
# Check if this is a tool result for active research
if active_research and input_data.messages:
tool_result = extract_tool_result(input_data.messages)
if tool_result:
logger.info(f"Received tool result: {tool_result}")
action = tool_result.get("action", "search")
questions = tool_result.get("questions")
decision = HumanDecision(
action=action,
questions=questions,
)
await active_research.queue.put(decision)
# Return acknowledgment - the original stream will continue
return StreamingResponse(
iter([format_sse_event({"type": "TOOL_RESULT_RECEIVED"})]),
media_type="text/event-stream",
)
user_message = "" user_message = ""
if input_data.messages: if input_data.messages:
user_message = input_data.messages[-1].get("content", "") user_message = input_data.messages[-1].get("content", "")
@ -100,8 +143,6 @@ async def stream_research_agent(request: Request) -> StreamingResponse:
ids_str = ", ".join(f"'{id}'" for id in document_ids) ids_str = ", ".join(f"'{id}'" for id in document_ids)
search_filter = f"id IN ({ids_str})" search_filter = f"id IN ({ids_str})"
thread_id = input_data.thread_id
# Create agent dependencies with shared emitter # Create agent dependencies with shared emitter
agent_deps = AgentDeps( agent_deps = AgentDeps(
client=client, client=client,
@ -121,9 +162,12 @@ async def stream_research_agent(request: Request) -> StreamingResponse:
# Forward emitter events to stream # Forward emitter events to stream
async def forward_events(): async def forward_events():
async for event in emitter: async for event in emitter:
# Log events for debugging
logger.info(f"AG-UI Event: {event}")
event_type = event.get("type") event_type = event.get("type")
logger.info(f"AG-UI event: {event_type}")
# Log tool call events for debugging
if event_type and event_type.startswith("TOOL_CALL"):
logger.info(f"Tool call event: {event}")
# Convert ACTIVITY_SNAPSHOT to STATE_DELTA for CopilotKit # Convert ACTIVITY_SNAPSHOT to STATE_DELTA for CopilotKit
# As CopilotKit does not handle ACTIVITY_SNAPSHOT events # As CopilotKit does not handle ACTIVITY_SNAPSHOT events
@ -133,7 +177,6 @@ async def stream_research_agent(request: Request) -> StreamingResponse:
message = content.get("message", "") message = content.get("message", "")
# Emit STATE_DELTA to patch activity info into state # Emit STATE_DELTA to patch activity info into state
# Use "add" op which creates or replaces the value
delta_event = { delta_event = {
"type": "STATE_DELTA", "type": "STATE_DELTA",
"delta": [ "delta": [
@ -152,22 +195,6 @@ async def stream_research_agent(request: Request) -> StreamingResponse:
await send_stream.send(format_sse_event(delta_event)) await send_stream.send(format_sse_event(delta_event))
continue continue
# When human_decision tool starts, set awaiting_decision flag
if event_type == "TOOL_CALL_START":
tool_name = event.get("toolCallName")
if tool_name == "human_decision":
delta_event = {
"type": "STATE_DELTA",
"delta": [
{
"op": "add",
"path": "/awaiting_decision",
"value": True,
}
],
}
await send_stream.send(format_sse_event(delta_event))
# Sync state to ActiveResearch when human_decision tool call # Sync state to ActiveResearch when human_decision tool call
if event_type == "TOOL_CALL_ARGS" and thread_id: if event_type == "TOOL_CALL_ARGS" and thread_id:
delta = event.get("delta", "{}") delta = event.get("delta", "{}")
@ -191,6 +218,9 @@ async def stream_research_agent(request: Request) -> StreamingResponse:
result = await agent.run(user_message, deps=agent_deps) result = await agent.run(user_message, deps=agent_deps)
emitter.log(result.output) emitter.log(result.output)
# Emit RUN_FINISHED with research result if available
if agent_deps.research_result:
emitter.finish_run(agent_deps.research_result)
await emitter.close() await emitter.close()
except Exception as e: except Exception as e:
@ -283,36 +313,10 @@ async def visualize_chunk(request: Request) -> JSONResponse:
) )
async def research_decide(request: Request) -> JSONResponse:
"""Endpoint to receive human decisions for active research."""
body = await request.json()
action = body.get("action", "search")
questions = body.get("questions", [])
# Get first active research (single-user example)
active = next(iter(_active_research.values()), None)
if not active:
return JSONResponse({"error": "No active research found"}, status_code=404)
# When "search" action is sent with questions, use "modify_questions" to update them
effective_action = (
"modify_questions" if action == "search" and questions else action
)
decision = HumanDecision(
action=effective_action,
questions=questions or None,
)
await active.queue.put(decision)
return JSONResponse({"status": "ok", "action": effective_action})
# Create Starlette app # Create Starlette app
app = Starlette( app = Starlette(
routes=[ routes=[
Route("/v1/research/stream", stream_research_agent, methods=["POST"]), Route("/v1/research/stream", stream_research_agent, methods=["POST"]),
Route("/v1/research/decide", research_decide, methods=["POST"]),
Route("/api/documents", list_documents, methods=["GET"]), Route("/api/documents", list_documents, methods=["GET"]),
Route("/api/visualize/{chunk_id}", visualize_chunk, methods=["GET"]), Route("/api/visualize/{chunk_id}", visualize_chunk, methods=["GET"]),
Route("/health", health_check, methods=["GET"]), Route("/health", health_check, methods=["GET"]),

View file

@ -4,7 +4,7 @@ FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm ci RUN npm install --legacy-peer-deps
COPY . . COPY . .
EXPOSE 3000 EXPOSE 3000

View file

@ -1,66 +1,282 @@
"use client"; "use client";
import { CopilotKit, useCoAgent } from "@copilotkit/react-core"; import {
CopilotKit,
useCoAgent,
useCopilotAction,
useCopilotContext,
} from "@copilotkit/react-core";
import { CopilotChat } from "@copilotkit/react-ui"; import { CopilotChat } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css"; import "@copilotkit/react-ui/styles.css";
import { useState, useEffect } from "react"; import { useState } from "react";
import DocumentSelector from "./DocumentSelector"; import DocumentSelector from "./DocumentSelector";
import StateDisplay from "./StateDisplay"; 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 { interface SearchAnswer {
query: string; query: string;
answer: string; answer: string;
confidence: number; confidence: number;
cited_chunks: string[]; cited_chunks: string[];
citations: Citation[]; citations: {
document_id: string;
chunk_id: string;
document_uri: string;
document_title?: string;
page_numbers: number[];
headings?: string[];
content: string;
}[];
} }
interface ResearchContext { interface ResearchState {
context: {
original_question: string;
sub_questions: string[];
qa_responses: SearchAnswer[];
};
iterations: number;
max_iterations: number;
confidence_threshold: number;
max_concurrency: number;
last_eval: {
new_questions: string[];
confidence_score: number;
is_sufficient: boolean;
reasoning: string;
} | null;
result?: {
title: string;
executive_summary: string;
main_findings: string[];
conclusions: string[];
limitations: string[];
recommendations: string[];
sources_summary: string;
};
current_activity?: string;
current_activity_message?: string;
documentFilter?: string[];
}
interface DecisionArgs {
original_question: string; original_question: string;
sub_questions: string[]; sub_questions: string[];
qa_responses: SearchAnswer[]; qa_responses: SearchAnswer[];
} }
interface EvaluationResult { type DecisionAction = "search" | "synthesize" | "modify_questions";
new_questions: string[];
confidence_score: number; interface DecisionResult {
is_sufficient: boolean; action: DecisionAction;
reasoning: string; questions?: string[];
} }
interface ResearchReport { function DecisionUI({
title: string; args,
executive_summary: string; onResolve,
main_findings: string[]; }: {
conclusions: string[]; args: DecisionArgs;
limitations: string[]; onResolve: (result: DecisionResult) => void | Promise<void>;
recommendations: string[]; }) {
sources_summary: string; const [editableQuestions, setEditableQuestions] = useState<string[]>(
args.sub_questions || [],
);
const [newQuestion, setNewQuestion] = useState("");
const [submitting, setSubmitting] = useState(false);
const qaCount = args.qa_responses?.length || 0;
const hasQuestions = editableQuestions.length > 0;
const canSearch = hasQuestions && !submitting;
const canSynthesize = qaCount > 0 && !submitting;
const questionsModified =
editableQuestions.length !== args.sub_questions.length ||
editableQuestions.some((q, i) => q !== args.sub_questions[i]);
const handleSubmit = (action: DecisionAction, questions?: string[]) => {
setSubmitting(true);
onResolve({ action, questions });
};
const handleSearch = () => {
handleSubmit(
questionsModified ? "modify_questions" : "search",
editableQuestions,
);
};
const handleSynthesize = () => {
handleSubmit("synthesize");
};
const handleRemoveQuestion = (index: number) => {
if (submitting) return;
setEditableQuestions(editableQuestions.filter((_, i) => i !== index));
};
const handleAddQuestion = () => {
if (submitting || !newQuestion.trim()) return;
setEditableQuestions([...editableQuestions, newQuestion.trim()]);
setNewQuestion("");
};
if (submitting) {
return null;
}
return (
<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",
}}
>
{qaCount} answers collected
</div>
<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={`question-${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)}
style={{
background: "#ef4444",
color: "white",
border: "none",
borderRadius: "4px",
padding: "0.25rem 0.5rem",
cursor: "pointer",
fontSize: "0.75rem",
}}
>
Remove
</button>
</div>
))}
</div>
<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..."
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}
style={{
background: "#22c55e",
color: "white",
border: "none",
borderRadius: "4px",
padding: "0.5rem 1rem",
cursor: "pointer",
fontSize: "0.85rem",
}}
>
Add
</button>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
type="button"
onClick={handleSearch}
disabled={!canSearch}
style={{
flex: 1,
background: canSearch ? "#0ea5e9" : "#94a3b8",
color: "white",
border: "none",
borderRadius: "4px",
padding: "0.75rem",
cursor: canSearch ? "pointer" : "not-allowed",
fontWeight: "bold",
fontSize: "0.9rem",
}}
>
Search ({editableQuestions.length})
</button>
<button
type="button"
onClick={handleSynthesize}
disabled={!canSynthesize}
style={{
flex: 1,
background: canSynthesize ? "#8b5cf6" : "#94a3b8",
color: "white",
border: "none",
borderRadius: "4px",
padding: "0.75rem",
cursor: canSynthesize ? "pointer" : "not-allowed",
fontWeight: "bold",
fontSize: "0.9rem",
}}
>
Generate Report
</button>
</div>
</div>
);
} }
interface ResearchState { const BACKEND_URL =
context: ResearchContext; process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000";
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() { function AgentContent() {
const { state, setState, running } = useCoAgent<ResearchState>({ const { state, setState, running } = useCoAgent<ResearchState>({
@ -77,61 +293,79 @@ function AgentContent() {
max_concurrency: 1, max_concurrency: 1,
last_eval: null, last_eval: null,
documentFilter: [], documentFilter: [],
awaiting_decision: false,
}, },
}); });
const [editableQuestions, setEditableQuestions] = useState<string[]>([]); const { threadId } = useCopilotContext();
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[]) => { const handleDocumentFilterChange = (ids: string[]) => {
setState({ ...state, documentFilter: ids }); setState({ ...state, documentFilter: ids });
}; };
const handleRemoveQuestion = (index: number) => { const sendToolResult = async (result: DecisionResult) => {
setEditableQuestions(editableQuestions.filter((_, i) => i !== index)); if (!threadId) {
}; console.error("No threadId available to send tool result");
return;
const handleAddQuestion = () => {
if (newQuestion.trim()) {
setEditableQuestions([...editableQuestions, newQuestion.trim()]);
setNewQuestion("");
} }
};
const handleDecision = async (action: "search" | "synthesize") => {
setSubmitting(true);
try { try {
const response = await fetch( const response = await fetch(`${BACKEND_URL}/v1/research/stream`, {
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/v1/research/decide`, method: "POST",
{ headers: { "Content-Type": "application/json" },
method: "POST", body: JSON.stringify({
headers: { "Content-Type": "application/json" }, threadId,
body: JSON.stringify({ messages: [
thread_id: state.context.original_question, // Use as identifier {
action, id: crypto.randomUUID(),
questions: editableQuestions, role: "tool",
}), content: JSON.stringify(result),
} },
); ],
if (response.ok) { }),
setState({ ...state, awaiting_decision: false }); });
if (!response.ok) {
console.error("Failed to send tool result:", response.status);
} }
} catch (error) { } catch (error) {
console.error("Failed to send decision:", error); console.error("Error sending tool result:", error);
} finally {
setSubmitting(false);
} }
}; };
useCopilotAction({
name: "human_decision",
description: "Pause for human decision on research direction",
parameters: [
{
name: "original_question",
type: "string",
description: "The original research question",
},
{
name: "sub_questions",
type: "string[]",
description: "Pending sub-questions to search",
},
{
name: "qa_responses",
type: "object[]",
description: "Answers collected so far",
},
],
renderAndWaitForResponse: ({ args, status }) => {
if (status === "complete") {
return null;
}
return (
<DecisionUI
args={args as unknown as DecisionArgs}
onResolve={sendToolResult}
/>
);
},
});
return ( return (
<> <>
<style>{` <style>{`
@ -148,7 +382,6 @@ function AgentContent() {
} }
`}</style> `}</style>
<div style={{ display: "flex", height: "100vh" }}> <div style={{ display: "flex", height: "100vh" }}>
{/* Chat on the left */}
<div className="chat-container"> <div className="chat-container">
<CopilotChat <CopilotChat
labels={{ labels={{
@ -159,7 +392,6 @@ function AgentContent() {
/> />
</div> </div>
{/* State display on the right */}
<div <div
style={{ style={{
width: "50%", width: "50%",
@ -191,7 +423,6 @@ function AgentContent() {
</p> </p>
</header> </header>
{/* Document filter - hidden when research is running */}
{!running && ( {!running && (
<div style={{ marginBottom: "1rem" }}> <div style={{ marginBottom: "1rem" }}>
<DocumentSelector <DocumentSelector
@ -201,153 +432,6 @@ function AgentContent() {
</div> </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} /> <StateDisplay state={state} />
</div> </div>
</div> </div>

View file

@ -162,7 +162,7 @@ export default function StateDisplay({ state }: StateDisplayProps) {
}} }}
> >
{/* Question */} {/* Question */}
{state.context.original_question && ( {state.context?.original_question && (
<div <div
style={{ style={{
background: "white", background: "white",
@ -364,7 +364,7 @@ export default function StateDisplay({ state }: StateDisplayProps) {
)} )}
{/* Answers */} {/* Answers */}
{state.context.qa_responses.length > 0 && ( {state.context?.qa_responses && state.context.qa_responses.length > 0 && (
<div <div
style={{ style={{
background: "white", background: "white",

View file

@ -12,9 +12,9 @@
}, },
"dependencies": { "dependencies": {
"@ag-ui/client": "^0.0.42", "@ag-ui/client": "^0.0.42",
"@copilotkit/react-core": "^1.10.6", "@copilotkit/react-core": "^1.50.0",
"@copilotkit/react-ui": "^1.10.6", "@copilotkit/react-ui": "^1.50.0",
"@copilotkit/runtime": "^1.10.6", "@copilotkit/runtime": "^1.50.0",
"next": "15.5.5", "next": "15.5.5",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"