Merge pull request #111 from ggozad/feat/ag-ui-example

Full AG-UI integration example featuring an interactive frontend doing research
This commit is contained in:
Yiorgis Gozadinos 2025-10-20 17:41:31 +03:00 committed by GitHub
commit c7e84aa2da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 17892 additions and 0 deletions

View file

@ -163,6 +163,14 @@ The A2A agent provides:
- Source citations with titles and URIs
- Full document retrieval on request
## Examples
See the [examples directory](examples/) for working examples:
- **[Interactive Research Assistant](examples/ag-ui-research/)** - Full-stack research assistant with Pydantic AI and AG-UI featuring human-in-the-loop approval and real-time state synchronization
- **[Docker Setup](examples/docker/)** - Complete Docker deployment with file monitoring, MCP server, and A2A agent
- **[A2A Security](examples/a2a-security/)** - Authentication examples (API key, OAuth2, GitHub)
## Documentation
Full documentation at: https://ggozad.github.io/haiku.rag/

View file

@ -6,6 +6,8 @@ Three agentic flows are provided by haiku.rag:
- Deep QA Agent — multi-agent question decomposition for complex questions
- Research MultiAgent — a multistep, analyzable research workflow
For an interactive example using Pydantic AI and AG-UI, see the [Interactive Research Assistant](https://github.com/ggozad/haiku.rag/tree/main/examples/ag-ui-research) example ([demo video](https://vimeo.com/1128874386)). The demo uses a knowledge base containing haiku.rag's code and documentation.
### Simple QA Agent

View file

@ -2,6 +2,19 @@
This directory contains example scripts demonstrating various features of haiku.rag.
## Interactive Research Assistant
**Directory:** `ag-ui-research/`
Full-stack research assistant with interactive UI powered by Pydantic AI and AG-UI:
- Multi-step research workflow with question decomposition
- Human-in-the-loop approval for research plans
- Real-time state synchronization between backend and frontend
- Context expansion and insight extraction
- Structured research reports with citations
See `ag-ui-research/README.md` for setup instructions.
## Docker Example
**Directory:** `docker/`

View file

@ -0,0 +1,23 @@
# QA Provider for the research agent (ollama, openai, anthropic, etc.)
QA_PROVIDER=ollama
# QA Model name
QA_MODEL=gpt-oss:latest
# Ollama base URL (only needed if using ollama provider)
# For Docker: http://host.docker.internal:11434
# For local development: http://localhost:11434
OLLAMA_BASE_URL=http://host.docker.internal:11434
# Path to the LanceDB database
# For Docker: /app/data/haiku_rag.lancedb
# For local development: Use absolute path to existing database
DB_PATH=~/SOME_FOLDER/haiku.rag.lancedb
# API keys (set as needed for your QA provider)
# OPENAI_API_KEY=your-key-here
# ANTHROPIC_API_KEY=your-key-here
# Embedding provider configuration (optional, defaults will be used)
# EMBEDDING_PROVIDER=openai
# EMBEDDING_MODEL=text-embedding-3-small

View file

@ -0,0 +1,80 @@
# Interactive Research Assistant
Research assistant powered by [haiku.rag](https://ggozad.github.io/haiku.rag/), [Pydantic AI](https://ai.pydantic.dev/), and [AG-UI](https://docs.ag-ui.com/). Ask complex questions and watch the research process unfold in real-time.
[Watch demo video](https://vimeo.com/1128874386)
## Features
- **Multi-step research workflow**: Question decomposition, search, analysis, and synthesis
- **Human-in-the-loop**: Approve or revise research plans before execution
- **Live state synchronization**: Real-time updates of research progress between backend and frontend
- **Context expansion**: Automatically expands top search results for better context
- **Rich reporting**: Generates structured reports with findings, conclusions, and citations
## Quick Start
### Prerequisites
- Docker and Docker Compose
- A haiku.rag database with indexed documents
- Ollama (or configure another LLM provider)
### Setup
1. **Prepare your knowledge base**
```bash
mkdir -p data
haiku-rag add "Your documents here" --db data/haiku_rag.lancedb
# Or add from files
haiku-rag add-src document.pdf --db data/haiku_rag.lancedb
```
2. **Configure environment** (optional)
```bash
cp .env.example .env
# Edit .env to customize provider/model
```
See [haiku.rag configuration](https://ggozad.github.io/haiku.rag/configuration/) for details.
3. **Start the application**
```bash
docker compose up --build
```
4. **Access the interface**
- Frontend: http://localhost:3000
- Backend health: http://localhost:8000/health
## How It Works
1. **Ask a question**: Type your research question in the chat
2. **Review the plan**: The agent decomposes your question into 3 sub-questions
3. **Approve or revise**: Choose to approve the plan or request changes
4. **Watch it work**: The agent automatically:
- Searches the knowledge base for each sub-question
- Extracts key insights from search results
- Evaluates overall confidence in findings
5. **Get your report**: Receive a structured research report with citations
## Architecture
- **Backend** (Python): Pydantic AI agent with haiku.rag integration
- `agent.py`: Research agent with tool definitions
- `main.py`: Starlette app serving AG-UI protocol
- **Frontend** (Next.js): CopilotKit/AG-UI interface
- Real-time state synchronization with backend
- Interactive approval workflow
- Collapsible research plan and insights display
## Configuration
Environment variables (see `.env.example`):
- `DB_PATH`: Path to haiku.rag database (default: `haiku_rag.lancedb`)
- `QA_PROVIDER`: LLM provider (default: `ollama`)
- `QA_MODEL`: Model name (default: `gpt-oss:latest`)
- `OLLAMA_BASE_URL`: Ollama endpoint (default: `http://host.docker.internal:11434`)
For other providers (OpenAI, Anthropic, etc.), see [haiku.rag configuration docs](https://ggozad.github.io/haiku.rag/configuration/).

View file

@ -0,0 +1,12 @@
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
.env
.venv
*.egg-info/
dist/
build/

View file

@ -0,0 +1,27 @@
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
WORKDIR /app
# Enable bytecode compilation
ENV UV_COMPILE_BYTECODE=1
# Copy from the cache instead of linking since it's a mounted volume
ENV UV_LINK_MODE=copy
# Install dependencies
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-install-project --no-dev
# Copy the project into the image
COPY . .
# Sync the project
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
EXPOSE 8000
# Run with uv
CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View file

@ -0,0 +1,17 @@
# Haiku.rag Research Assistant Backend
FastAPI backend for the haiku.rag interactive research assistant, using Pydantic AI with AG-UI protocol support.
## Setup
```bash
uv sync
uv run python main.py
```
The server starts on `http://localhost:8000` and uses [haiku.rag configuration](https://ggozad.github.io/haiku.rag/configuration/).
## Endpoints
- `GET /health` - Health check
- `POST /agent` - AG-UI protocol endpoint

View file

@ -0,0 +1,431 @@
import json
from dataclasses import dataclass
from ag_ui.core import EventType, StateSnapshotEvent
from pydantic import BaseModel
from pydantic_ai import Agent, RunContext
from pydantic_ai.ag_ui import StateDeps
from haiku.rag.client import HaikuRAG
from haiku.rag.config import Config
from haiku.rag.graph.common import get_model
class ResearchState(BaseModel):
"""Shared state between research agent and frontend."""
question: str = ""
phase: str = "idle"
status: str = ""
plan: list[dict] = []
current_question_index: int = 0
insights: list[dict] = []
document_registry: dict[str, dict] = {}
current_document: dict | None = None
confidence: float = 0.0
final_report: dict | None = None
@dataclass
class ResearchDeps(StateDeps[ResearchState]):
"""Dependencies for the research agent with HaikuRAG client."""
client: HaikuRAG
def _as_state_snapshot(ctx: RunContext[ResearchDeps]) -> StateSnapshotEvent:
return StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot=ctx.deps.state)
def create_agent(
qa_provider: str = Config.QA_PROVIDER, qa_model: str = Config.QA_MODEL
) -> Agent[ResearchDeps, str]:
"""Create and configure the research agent.
Args:
qa_provider: QA provider for the agent (default: from Config.QA_PROVIDER)
qa_model: Model name to use (default: from Config.QA_MODEL)
"""
print(f"[AGENT SETUP] Creating agent with provider={qa_provider}, model={qa_model}")
agent = Agent(
model=get_model(qa_provider, qa_model),
deps_type=ResearchDeps,
instructions="""You are a research co-pilot powered by haiku.rag.
Your workflow MUST follow these exact steps in order:
1. Call propose_research_plan with the user's question
2. After propose_research_plan completes, IMMEDIATELY call approve_research_plan (with no arguments)
3. WAIT for approve_research_plan to return:
- If it returns "APPROVED", proceed to step 4
- If it returns "REVISE", ask the user "How would you like me to revise the research plan?" and wait for their response
- Once you receive their revision feedback, revise the plan and go back to step 1
4. Once approved, process questions ONE AT A TIME:
- Call search_question(question_id=0) and WAIT for it to complete
- Then call extract_insights_from_results(question_id=0) and WAIT for it to complete
- Then call search_question(question_id=1) and WAIT for it to complete
- Then call extract_insights_from_results(question_id=1) and WAIT for it to complete
- Then call search_question(question_id=2) and WAIT for it to complete
- Then call extract_insights_from_results(question_id=2) and WAIT for it to complete
5. After all questions are processed, call evaluate_research_confidence
6. Ask user if they want to finalize or continue researching
7. When user approves, call synthesize_final_report
CRITICAL RULES:
- MANDATORY: Call approve_research_plan immediately after propose_research_plan - NO EXCEPTIONS
- If approve_research_plan returns "REVISE", ask the user for revision feedback naturally in chat
- Call ONE tool at a time - wait for each tool to return before calling the next
- NEVER call extract_insights_from_results until search_question has completed and returned results
- DO NOT explain what you're about to do - just call the tool
- The state updates will show the user what's happening - you don't need to narrate
- Process all 3 questions automatically without asking for approval between them
Document Viewing:
- When user asks to "show document X", call get_full_document with the document_uri
Remember: Call tools ONE AT A TIME in sequence. Each tool must complete before calling the next.
""",
)
@agent.tool
async def propose_research_plan(
ctx: RunContext[ResearchDeps], question: str
) -> StateSnapshotEvent:
"""Propose a research plan by decomposing the question into sub-questions."""
ctx.deps.state.question = question
ctx.deps.state.phase = "planning"
ctx.deps.state.status = "Decomposing question into sub-questions..."
decompose_prompt = f"""Break down this research question into exactly 3 specific sub-questions that would help answer it comprehensively.
Research Question: {question}
Return ONLY a JSON array of sub-questions, like: ["Question 1?", "Question 2?", "Question 3?"]"""
response = await ctx.deps.client.ask(decompose_prompt)
try:
sub_questions = json.loads(response)
except json.JSONDecodeError:
sub_questions = [
q.strip().lstrip("0123456789.-) ")
for q in response.split("\n")
if q.strip()
][:3]
plan = [
{"id": i, "question": q, "status": "pending"}
for i, q in enumerate(sub_questions)
]
ctx.deps.state.plan = plan
ctx.deps.state.current_question_index = 0
ctx.deps.state.status = f"Proposed plan with {len(plan)} sub-questions"
return _as_state_snapshot(ctx)
@agent.tool
async def search_question(
ctx: RunContext[ResearchDeps],
question_id: int,
search_type: str = "hybrid",
) -> StateSnapshotEvent:
"""Execute search for a specific sub-question."""
plan = ctx.deps.state.plan
if question_id >= len(plan):
raise ValueError(f"Question ID {question_id} not found in plan")
question = plan[question_id]["question"]
ctx.deps.state.phase = "searching"
ctx.deps.state.current_question_index = question_id
ctx.deps.state.status = f"Searching: {question}"
plan[question_id]["status"] = "searching"
search_results = await ctx.deps.client.search(
question, limit=5, search_type=search_type
)
expanded_map = {}
if search_results:
expanded_results = await ctx.deps.client.expand_context(
search_results[:3], radius=2
)
expanded_map = {
chunk.id: (chunk, score) for chunk, score in expanded_results
}
results = []
for chunk, score in search_results:
doc_uri = chunk.document_uri or "unknown"
doc_title = chunk.document_title or chunk.document_uri or "Unknown"
if doc_uri not in ctx.deps.state.document_registry:
ctx.deps.state.document_registry[doc_uri] = {
"title": doc_title,
"chunks_referenced": [],
}
if (
chunk.id
not in ctx.deps.state.document_registry[doc_uri]["chunks_referenced"]
):
ctx.deps.state.document_registry[doc_uri]["chunks_referenced"].append(
chunk.id
)
expanded_chunk, _ = (
expanded_map[chunk.id] if chunk.id in expanded_map else (chunk, score)
)
result_data = {
"chunk": expanded_chunk.content[:500],
"chunk_id": chunk.id,
"document_uri": doc_uri,
"document_title": doc_title,
"chunk_position": chunk.order,
"full_chunk_content": expanded_chunk.content,
"score": round(score, 3),
"expanded": chunk.id in expanded_map,
}
results.append(result_data)
plan[question_id]["search_results"] = {
"type": search_type,
"results": results,
}
plan[question_id]["status"] = "searched"
ctx.deps.state.status = f"Found {len(results)} results"
return _as_state_snapshot(ctx)
@agent.tool
async def extract_insights_from_results(
ctx: RunContext[ResearchDeps],
question_id: int,
) -> StateSnapshotEvent:
"""Extract key insights from search results for a specific question."""
plan = ctx.deps.state.plan
if question_id >= len(plan):
raise ValueError(f"Question ID {question_id} not found in plan")
question_item = plan[question_id]
if "search_results" not in question_item:
raise ValueError(
f"No search results found for question ID {question_id}. "
f"You must call search_question(question_id={question_id}) first."
)
search_results = question_item["search_results"]
ctx.deps.state.phase = "analyzing"
ctx.deps.state.status = "Extracting insights from results..."
context_parts = [
f"[Result {idx}] [Source: {r['document_title']}] {r['full_chunk_content']}"
for idx, r in enumerate(search_results["results"])
]
context = "\n\n".join(context_parts)
class InsightResult(BaseModel):
summary: str
confidence: float
result_indices: list[int]
class InsightsList(BaseModel):
insights: list[InsightResult]
question_text = question_item["question"]
extract_prompt = f"""Analyze these search results and extract 1-3 key insights that help answer the question: "{question_text}"
Search Results:
{context}
For each insight, reference which result numbers (0, 1, 2, etc.) support it."""
insight_agent: Agent[None, InsightsList] = Agent(
ctx.model,
output_type=InsightsList,
)
result = await insight_agent.run(extract_prompt)
raw_insights = [
{
"summary": insight.summary,
"confidence": insight.confidence,
"result_indices": insight.result_indices,
}
for insight in result.output.insights
]
new_insights = []
for insight in raw_insights:
source_refs = []
for idx in insight.get("result_indices", []):
if 0 <= idx < len(search_results["results"]):
result = search_results["results"][idx]
source_refs.append(
{
"chunk_id": result["chunk_id"],
"document_uri": result["document_uri"],
"document_title": result["document_title"],
"chunk_position": result["chunk_position"],
}
)
new_insights.append(
{
"summary": insight["summary"],
"confidence": insight.get("confidence", 0.7),
"source_refs": source_refs,
}
)
ctx.deps.state.insights.extend(new_insights)
plan[question_id]["status"] = "done"
ctx.deps.state.status = f"Extracted {len(new_insights)} insights"
return _as_state_snapshot(ctx)
@agent.tool
async def evaluate_research_confidence(
ctx: RunContext[ResearchDeps],
) -> StateSnapshotEvent:
"""Evaluate overall confidence in the research findings."""
insights = ctx.deps.state.insights
if not insights:
raise ValueError("No insights collected yet")
ctx.deps.state.phase = "evaluating"
ctx.deps.state.status = "Evaluating research confidence..."
confidences = [i.get("confidence", 0.5) for i in insights]
overall_confidence = sum(confidences) / len(confidences) if confidences else 0
eval_prompt = f"""Evaluate if these insights provide a confident answer to: "{ctx.deps.state.question}"
Insights collected:
{chr(10).join([f"- {i['summary']}" for i in insights])}
Assess:
1. Do we have enough information to answer the question?
2. What gaps remain?
3. Overall confidence (0.0-1.0)
Return JSON: {{"confidence": 0.0-1.0, "gaps": ["gap1", "gap2"], "recommendation": "continue" or "finalize"}}"""
response = await ctx.deps.client.ask(eval_prompt)
try:
evaluation = json.loads(response)
overall_confidence = evaluation.get("confidence", overall_confidence)
except json.JSONDecodeError:
pass
ctx.deps.state.confidence = overall_confidence
ctx.deps.state.status = f"Confidence: {overall_confidence:.0%}"
return _as_state_snapshot(ctx)
@agent.tool
async def synthesize_final_report(
ctx: RunContext[ResearchDeps],
) -> StateSnapshotEvent:
"""Generate final research report with citations."""
insights = ctx.deps.state.insights
if not insights:
raise ValueError("No insights to synthesize")
ctx.deps.state.phase = "synthesizing"
ctx.deps.state.status = "Generating final report..."
insights_summary = []
for i in insights:
source_titles = [ref["document_title"] for ref in i.get("source_refs", [])]
unique_sources = list(dict.fromkeys(source_titles))
insights_summary.append(
f"- {i['summary']} (sources: {', '.join(unique_sources[:2])})"
)
report_prompt = f"""Generate a comprehensive research report answering: "{ctx.deps.state.question}"
Based on these insights:
{chr(10).join(insights_summary)}
Create a structured report with:
- Executive Summary (2-3 sentences)
- Main Findings (bullet points)
- Conclusions
- Sources (list the document titles mentioned above)
Return JSON with format:
{{
"title": "...",
"summary": "...",
"findings": ["finding1", "finding2", ...],
"conclusions": ["conclusion1", ...],
"sources": ["source1", "source2", ...]
}}"""
response = await ctx.deps.client.ask(report_prompt)
try:
report = json.loads(response)
except json.JSONDecodeError:
report = {
"title": ctx.deps.state.question,
"summary": response[:300],
"findings": [i["summary"] for i in insights],
"conclusions": ["See findings above"],
"sources": [],
}
citations = [
{
"document_uri": doc_uri,
"document_title": doc_info["title"],
"chunk_ids": doc_info["chunks_referenced"],
}
for doc_uri, doc_info in ctx.deps.state.document_registry.items()
]
report["citations"] = citations
ctx.deps.state.final_report = report
ctx.deps.state.phase = "done"
ctx.deps.state.status = "Research complete"
return _as_state_snapshot(ctx)
@agent.tool
async def get_full_document(
ctx: RunContext[ResearchDeps],
document_uri: str,
) -> StateSnapshotEvent:
"""Retrieve and display the full content of a document by its URI."""
ctx.deps.state.status = f"Retrieving document: {document_uri}"
document = await ctx.deps.client.get_document_by_uri(document_uri)
if document is None:
ctx.deps.state.status = f"Document not found: {document_uri}"
ctx.deps.state.current_document = {
"uri": document_uri,
"title": "Not Found",
"content": f"Document with URI '{document_uri}' was not found.",
"total_chunks": 0,
}
else:
all_chunks = await ctx.deps.client.search(
query="", limit=1000, search_type="fts"
)
chunks_for_doc = [
c for c, _ in all_chunks if c.document_uri == document_uri
]
ctx.deps.state.current_document = {
"uri": document.uri or document_uri,
"title": document.title or "Untitled",
"content": document.content,
"total_chunks": len(chunks_for_doc),
"metadata": document.metadata,
}
ctx.deps.state.status = f"Retrieved: {document.title or document_uri}"
return _as_state_snapshot(ctx)
return agent

View file

@ -0,0 +1,111 @@
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
from agent import ResearchDeps, ResearchState, create_agent
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.responses import JSONResponse
from starlette.routing import Mount, Route
from haiku.rag.client import HaikuRAG
from haiku.rag.config import Config
logger = logging.getLogger(__name__)
client: HaikuRAG | None = None
ag_ui_app = None
@asynccontextmanager
async def lifespan(app):
global client
db_path_str = os.getenv("DB_PATH", "haiku_rag.lancedb")
db_path = Path(db_path_str)
if not db_path.exists():
logger.error(f"Database not found at {db_path}")
logger.error("Run: haiku-rag add <path-to-documents>")
raise RuntimeError(f"Database not found: {db_path}")
logger.info(f"Initializing HaikuRAG client with database: {db_path}")
client = HaikuRAG(db_path)
logger.info("Research assistant backend ready")
logger.info(f"QA Provider: {Config.QA_PROVIDER}, Model: {Config.QA_MODEL}")
yield
if client:
logger.info("Closing HaikuRAG client")
client.close()
agent = create_agent()
async def health(request):
db_path_str = os.getenv("DB_PATH", "haiku_rag.lancedb")
return JSONResponse(
{
"status": "healthy",
"agent_model": str(agent.model),
"qa_provider": Config.QA_PROVIDER,
"qa_model": Config.QA_MODEL,
"ollama_base_url": Config.OLLAMA_BASE_URL,
"db_path": db_path_str,
"db_exists": Path(db_path_str).exists(),
}
)
def get_ag_ui_app():
global ag_ui_app
if ag_ui_app is None and client is not None:
research_deps = ResearchDeps(client=client, state=ResearchState())
logger.info("Creating AG-UI app")
ag_ui_app = agent.to_ag_ui(deps=research_deps)
return ag_ui_app
async def agent_endpoint(scope, receive, send):
app = get_ag_ui_app()
if app is None:
response = JSONResponse({"error": "Client not initialized"}, status_code=503)
await response(scope, receive, send)
return
await app(scope, receive, send)
app = Starlette(
routes=[
Route("/health", health),
Mount("/agent", agent_endpoint),
],
middleware=[
Middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://frontend:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
],
lifespan=lifespan,
)
if __name__ == "__main__":
import uvicorn
print("Starting haiku.rag research assistant backend...")
print(f"Agent model: {agent.model}")
print(f"QA provider: {Config.QA_PROVIDER}")
print(f"QA model: {Config.QA_MODEL}")
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=True,
)

View file

@ -0,0 +1,26 @@
[project]
name = "haiku-rag-research-assistant"
version = "0.1.0"
description = "Haiku.rag research assistant with AG-UI protocol support"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"starlette>=0.45.2",
"uvicorn[standard]>=0.34.2",
"pydantic-ai-slim[ag-ui,openai]>=1.1.0",
"python-dotenv>=1.0.1",
"haiku-rag>=0.12.1",
]
[dependency-groups]
dev = [
"pyright>=1.1.406",
"ruff>=0.13.0",
]
[tool.hatch.build.targets.wheel]
packages = ["."]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,50 @@
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8000:8000"
environment:
- QA_PROVIDER=${QA_PROVIDER:-ollama}
- QA_MODEL=${QA_MODEL:-gpt-oss:latest}
- DB_PATH=/app/data/haiku.rag.lancedb
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
volumes:
- ./backend:/app
- /app/.venv
- ${DB_PATH}:/app/data/haiku.rag.lancedb
networks:
- ag-ui-network
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- BACKEND_URL=http://backend:8000
volumes:
- ./frontend:/app
- /app/node_modules
- /app/.next
depends_on:
- backend
networks:
- ag-ui-network
restart: unless-stopped
networks:
ag-ui-network:
driver: bridge

View file

@ -0,0 +1,7 @@
node_modules
.next
.git
.gitignore
README.md
npm-debug.log
.env*.local

View file

@ -0,0 +1,39 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View file

@ -0,0 +1,12 @@
# Development Dockerfile for Next.js frontend
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
EXPOSE 3000
# Run in development mode with hot reload
CMD ["npm", "run", "dev"]

View file

@ -0,0 +1,33 @@
import { HttpAgent } from "@ag-ui/client";
import {
CopilotRuntime,
copilotRuntimeNextJSAppRouterEndpoint,
ExperimentalEmptyAdapter,
} from "@copilotkit/runtime";
import type { NextRequest } from "next/server";
// Connect CopilotKit to PydanticAI via HttpAgent
// The HttpAgent creates a bridge between the Next.js frontend and the Python backend
// It communicates with the server created by agent.to_ag_ui()
const runtime = new CopilotRuntime({
agents: {
// "research_agent" maps to the agent name used in useCoAgent() on the frontend
research_agent: new HttpAgent({
url: `${process.env.BACKEND_URL || "http://backend:8000"}/agent`,
}),
},
});
// Service adapter for multi-agent support (empty since we only have one agent)
const serviceAdapter = new ExperimentalEmptyAdapter();
// Next.js API route handler that proxies requests between frontend and backend
export async function POST(request: NextRequest) {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter,
endpoint: "/api/copilotkit",
});
return handleRequest(request);
}

View file

@ -0,0 +1,24 @@
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
font-family:
system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
}
body {
background: linear-gradient(to bottom, #f8f9fa, #e9ecef);
min-height: 100vh;
}
a {
color: inherit;
text-decoration: none;
}

View file

@ -0,0 +1,20 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Haiku.rag Research Assistant",
description:
"Interactive research powered by Haiku.rag, Pydantic AI, and AG-UI",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

View file

@ -0,0 +1,9 @@
import Agent from "@/components/Agent";
export default function Home() {
return (
<main>
<Agent />
</main>
);
}

View file

@ -0,0 +1,34 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.6/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

View file

@ -0,0 +1,282 @@
"use client";
import {
CopilotKit,
useCoAgent,
useCoAgentStateRender,
useCopilotAction,
} from "@copilotkit/react-core";
import { CopilotChat } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";
import StateDisplay from "./StateDisplay";
interface SourceRef {
chunk_id: string;
document_uri: string;
document_title: string;
chunk_position: number;
}
interface ResearchState {
question: string;
phase: string;
status: string;
plan: Array<{
id: number;
question: string;
status: string;
search_results?: {
type: string;
results: Array<{
chunk: string;
chunk_id: string;
document_uri: string;
document_title: string;
chunk_position: number;
full_chunk_content: string;
score: number;
expanded: boolean;
}>;
};
}>;
current_question_index: number;
insights: Array<{
summary: string;
confidence: number;
source_refs: SourceRef[];
}>;
document_registry: Record<
string,
{
title: string;
chunks_referenced: string[];
}
>;
current_document: {
uri: string;
title: string;
content: string;
total_chunks: number;
metadata?: Record<string, unknown>;
} | null;
confidence: number;
final_report: {
title: string;
summary: string;
findings: string[];
conclusions: string[];
sources: string[];
citations: Array<{
document_uri: string;
document_title: string;
chunk_ids: string[];
}>;
} | null;
}
function AgentContent() {
const { state } = useCoAgent<ResearchState>({
name: "research_agent",
initialState: {
question: "",
phase: "idle",
status: "",
plan: [],
current_question_index: 0,
insights: [],
document_registry: {},
current_document: null,
confidence: 0.0,
final_report: null,
},
});
useCopilotAction({
name: "approve_research_plan",
description:
"Request user approval for the research plan. Returns 'APPROVED' if approved or 'REVISE' if user wants to revise.",
parameters: [],
renderAndWaitForResponse: ({ respond, status }) => (
<div
style={{
padding: "1.5rem",
background: "white",
borderRadius: "8px",
border: "2px solid #4299e1",
marginBottom: "1rem",
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
}}
>
<h3
style={{
fontSize: "1.25rem",
fontWeight: "bold",
marginBottom: "1rem",
color: "#2d3748",
}}
>
Research Plan Approval
</h3>
<p
style={{
fontSize: "0.875rem",
color: "#4a5568",
marginBottom: "1rem",
}}
>
Please review the research plan in the right pane.
</p>
<div
style={{
display: "flex",
gap: "1rem",
}}
className={status !== "executing" ? "hidden" : ""}
>
<button
type="button"
onClick={() => respond?.("REVISE")}
disabled={status !== "executing"}
style={{
flex: 1,
padding: "0.75rem",
background: "white",
border: "2px solid #e2e8f0",
borderRadius: "6px",
fontSize: "0.875rem",
fontWeight: "600",
cursor: status === "executing" ? "pointer" : "not-allowed",
opacity: status === "executing" ? 1 : 0.5,
}}
>
Revise Plan
</button>
<button
type="button"
onClick={() => respond?.("APPROVED")}
disabled={status !== "executing"}
style={{
flex: 1,
padding: "0.75rem",
background: "#4299e1",
color: "white",
border: "none",
borderRadius: "6px",
fontSize: "0.875rem",
fontWeight: "600",
cursor: status === "executing" ? "pointer" : "not-allowed",
opacity: status === "executing" ? 1 : 0.5,
}}
>
Approve & Start Research
</button>
</div>
</div>
),
});
useCoAgentStateRender<ResearchState>({
name: "research_agent",
render: ({ state: newState }) => {
const phaseMessages: Record<string, string> = {
planning: "Planning research...",
searching: "Searching...",
analyzing: "Extracting insights...",
evaluating: `Evaluating confidence: ${(newState.confidence * 100).toFixed(0)}%`,
synthesizing: "Generating final report...",
done: "Research complete!",
};
const phaseMessage =
phaseMessages[newState.phase] || newState.status || "Ready";
return (
<div
style={{
padding: "1rem",
background: "#e6f7ff",
borderRadius: "4px",
marginBottom: "0.5rem",
border: "1px solid #91d5ff",
}}
>
<strong>Research Update:</strong> {phaseMessage}
</div>
);
},
});
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>
<StateDisplay state={state} />
</div>
</div>
</div>
</>
);
}
export default function Agent() {
return (
<CopilotKit runtimeUrl="/api/copilotkit" agent="research_agent">
<AgentContent />
</CopilotKit>
);
}

View file

@ -0,0 +1,862 @@
"use client";
import { Markdown } from "@copilotkit/react-ui";
import { useState } from "react";
interface SourceRef {
chunk_id: string;
document_uri: string;
document_title: string;
chunk_position: number;
}
interface ResearchState {
question: string;
phase: string;
status: string;
plan: Array<{
id: number;
question: string;
status: string;
search_results?: {
type: string;
results: Array<{
chunk: string;
chunk_id: string;
document_uri: string;
document_title: string;
chunk_position: number;
full_chunk_content: string;
score: number;
expanded: boolean;
}>;
};
}>;
current_question_index: number;
insights: Array<{
summary: string;
confidence: number;
source_refs: SourceRef[];
}>;
document_registry: Record<
string,
{
title: string;
chunks_referenced: string[];
}
>;
current_document: {
uri: string;
title: string;
content: string;
total_chunks: number;
metadata?: Record<string, unknown>;
} | null;
confidence: number;
final_report: {
title: string;
summary: string;
findings: string[];
conclusions: string[];
sources: string[];
citations: Array<{
document_uri: string;
document_title: string;
chunk_ids: string[];
}>;
} | null;
}
interface StateDisplayProps {
state: ResearchState;
}
export default function StateDisplay({ state }: StateDisplayProps) {
const [expandedSections, setExpandedSections] = useState<
Record<string, boolean>
>({
plan: true,
insights: true,
report: true,
document: true,
});
const [expandedQuestions, setExpandedQuestions] = useState<
Record<number, boolean>
>({});
const toggleSection = (section: string) => {
setExpandedSections((prev) => ({
...prev,
[section]: !prev[section],
}));
};
const toggleQuestion = (questionId: number) => {
setExpandedQuestions((prev) => ({
...prev,
[questionId]: !prev[questionId],
}));
};
// Calculate research progress
const completedQuestions = state.plan.filter(
(q) => q.status === "done",
).length;
const totalQuestions = state.plan.length;
const researchProgress =
totalQuestions > 0 ? (completedQuestions / totalQuestions) * 100 : 0;
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
{/* Current Phase & Status */}
<div
style={{
background: "white",
borderRadius: "8px",
padding: "1.5rem",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
}}
>
<div
style={{
fontSize: "0.875rem",
color: "#718096",
marginBottom: "0.5rem",
}}
>
Current Phase
</div>
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
<div
style={{
padding: "0.5rem 1rem",
background:
state.phase === "idle"
? "#e2e8f0"
: state.phase === "planning"
? "#fef3c7"
: state.phase === "searching"
? "#dbeafe"
: state.phase === "analyzing"
? "#e0e7ff"
: state.phase === "evaluating"
? "#fce7f3"
: "#d1fae5",
color:
state.phase === "idle"
? "#718096"
: state.phase === "planning"
? "#92400e"
: state.phase === "searching"
? "#1e40af"
: state.phase === "analyzing"
? "#3730a3"
: state.phase === "evaluating"
? "#9f1239"
: "#065f46",
borderRadius: "6px",
fontSize: "1rem",
fontWeight: "700",
textTransform: "capitalize",
}}
>
{state.phase}
</div>
{state.status && (
<div
style={{
fontSize: "0.875rem",
color: "#4a5568",
}}
>
{state.status}
</div>
)}
</div>
{/* Research Progress Bar */}
{totalQuestions > 0 && state.phase !== "idle" && (
<div style={{ marginTop: "1rem" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.5rem",
}}
>
<span
style={{
fontSize: "0.75rem",
color: "#718096",
}}
>
Research Progress
</span>
<span
style={{
fontSize: "0.75rem",
fontWeight: "600",
color: "#2d3748",
}}
>
{completedQuestions}/{totalQuestions} questions
</span>
</div>
<div
style={{
height: "0.5rem",
background: "#e2e8f0",
borderRadius: "4px",
overflow: "hidden",
}}
>
<div
style={{
width: `${researchProgress}%`,
height: "100%",
background: "#48bb78",
transition: "width 0.3s ease",
}}
/>
</div>
</div>
)}
</div>
{/* Question */}
{state.question && (
<div
style={{
background: "white",
borderRadius: "8px",
padding: "1.5rem",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
}}
>
<div
style={{
fontSize: "0.875rem",
color: "#718096",
marginBottom: "0.25rem",
}}
>
Question
</div>
<div
style={{
fontSize: "1.125rem",
fontWeight: "bold",
color: "#2d3748",
}}
>
{state.question}
</div>
</div>
)}
{/* Confidence Meter */}
{state.confidence > 0 && (
<div
style={{
background: "white",
borderRadius: "8px",
padding: "1.5rem",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
}}
>
<div
style={{
fontSize: "0.875rem",
color: "#718096",
marginBottom: "0.5rem",
}}
>
Confidence
</div>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<div
style={{
flex: 1,
height: "1rem",
background: "#e2e8f0",
borderRadius: "4px",
overflow: "hidden",
}}
>
<div
style={{
width: `${state.confidence * 100}%`,
height: "100%",
background:
state.confidence > 0.8
? "#48bb78"
: state.confidence > 0.5
? "#ed8936"
: "#f56565",
transition: "width 0.3s ease",
}}
/>
</div>
<div
style={{
fontSize: "1.5rem",
fontWeight: "bold",
color:
state.confidence > 0.8
? "#48bb78"
: state.confidence > 0.5
? "#ed8936"
: "#f56565",
}}
>
{(state.confidence * 100).toFixed(0)}%
</div>
</div>
</div>
)}
{/* Research Plan */}
{state.plan.length > 0 && (
<div
style={{
background: "white",
borderRadius: "8px",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
overflow: "hidden",
}}
>
<button
type="button"
onClick={() => toggleSection("plan")}
style={{
width: "100%",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0.75rem",
background: "#edf2f7",
border: "1px solid #e2e8f0",
borderRadius: "4px",
cursor: "pointer",
fontSize: "1rem",
fontWeight: "600",
color: "#2d3748",
}}
>
<span>Research Plan ({state.plan.length} questions)</span>
<span>{expandedSections.plan ? "▼" : "▶"}</span>
</button>
{expandedSections.plan && (
<div
style={{
padding: "1rem",
background: "#f7fafc",
border: "1px solid #e2e8f0",
borderTop: "none",
borderRadius: "0 0 4px 4px",
}}
>
{state.plan.map((item) => (
<div
key={item.id}
style={{
marginBottom: "0.5rem",
background: "white",
borderRadius: "4px",
border: "1px solid #e2e8f0",
overflow: "hidden",
}}
>
<button
type="button"
onClick={() => toggleQuestion(item.id)}
style={{
width: "100%",
display: "flex",
gap: "0.75rem",
padding: "0.75rem",
background: "white",
border: "none",
cursor: "pointer",
textAlign: "left",
alignItems: "center",
}}
>
<div
style={{
fontSize: "1.25rem",
color:
item.status === "done"
? "#48bb78"
: item.status === "searching" ||
item.status === "searched"
? "#4299e1"
: "#a0aec0",
flexShrink: 0,
}}
>
{item.status === "done"
? "✓"
: item.status === "searching"
? "🔍"
: item.status === "searched"
? "📊"
: "⏳"}
</div>
<div style={{ flex: 1 }}>
<div
style={{
fontSize: "0.875rem",
color: "#4a5568",
}}
>
<Markdown content={item.question} />
</div>
{item.search_results && (
<div
style={{
fontSize: "0.75rem",
color: "#718096",
marginTop: "0.25rem",
}}
>
{item.search_results.results.length} results
</div>
)}
</div>
{item.search_results && (
<span
style={{
fontSize: "0.875rem",
color: "#718096",
}}
>
{expandedQuestions[item.id] ? "▼" : "▶"}
</span>
)}
</button>
{/* Search Results nested inside question */}
{expandedQuestions[item.id] && item.search_results && (
<div
style={{
padding: "1rem",
background: "#f7fafc",
borderTop: "1px solid #e2e8f0",
}}
>
<div
style={{
fontSize: "0.75rem",
color: "#718096",
marginBottom: "0.5rem",
fontWeight: "600",
}}
>
Search Type: {item.search_results.type}
</div>
{item.search_results.results.map((result, idx) => (
<div
key={`${result.chunk_id}-${idx}`}
style={{
padding: "0.75rem",
background: "white",
borderRadius: "4px",
marginBottom: "0.5rem",
border: "1px solid #e2e8f0",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: "0.5rem",
}}
>
<span
style={{
fontSize: "0.875rem",
fontWeight: "600",
color: "#2d3748",
}}
>
{result.document_title}
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{result.expanded && (
<span
style={{
fontSize: "0.75rem",
padding: "0.125rem 0.5rem",
background: "#bee3f8",
color: "#2c5282",
borderRadius: "4px",
}}
>
Expanded
</span>
)}
<span
style={{
fontSize: "0.875rem",
fontWeight: "bold",
color:
result.score > 0.8
? "#48bb78"
: result.score > 0.6
? "#ed8936"
: "#a0aec0",
}}
>
{result.score.toFixed(2)}
</span>
</div>
</div>
<div
style={{
fontSize: "0.875rem",
color: "#718096",
lineHeight: "1.4",
}}
>
<Markdown content={`${result.chunk}...`} />
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Insights */}
{state.insights.length > 0 && (
<div
style={{
background: "white",
borderRadius: "8px",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
overflow: "hidden",
}}
>
<button
type="button"
onClick={() => toggleSection("insights")}
style={{
width: "100%",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0.75rem",
background: "#edf2f7",
border: "1px solid #e2e8f0",
borderRadius: "4px",
cursor: "pointer",
fontSize: "1rem",
fontWeight: "600",
color: "#2d3748",
}}
>
<span>Key Insights ({state.insights.length})</span>
<span>{expandedSections.insights ? "▼" : "▶"}</span>
</button>
{expandedSections.insights && (
<div
style={{
padding: "1rem",
background: "#f7fafc",
border: "1px solid #e2e8f0",
borderTop: "none",
borderRadius: "0 0 4px 4px",
}}
>
{state.insights.map((insight, idx) => (
<div
key={`${insight.summary.substring(0, 30)}-${idx}`}
style={{
padding: "0.75rem",
background: "white",
borderRadius: "4px",
marginBottom: "0.5rem",
border: "1px solid #e2e8f0",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: "0.5rem",
}}
>
<span
style={{
fontSize: "0.75rem",
padding: "0.125rem 0.5rem",
background: "#c6f6d5",
color: "#22543d",
borderRadius: "4px",
}}
>
{(insight.confidence * 100).toFixed(0)}% confidence
</span>
<span
style={{
fontSize: "0.75rem",
color: "#718096",
}}
>
{insight.source_refs?.length || 0} sources
</span>
</div>
<div
style={{
fontSize: "0.875rem",
color: "#2d3748",
lineHeight: "1.5",
marginBottom: "0.5rem",
}}
>
<Markdown content={insight.summary} />
</div>
{insight.source_refs && insight.source_refs.length > 0 && (
<div
style={{
fontSize: "0.75rem",
color: "#718096",
marginTop: "0.5rem",
}}
>
<span style={{ fontWeight: "600" }}>Sources: </span>
{insight.source_refs.map((ref, refIdx) => (
<span key={ref.chunk_id}>
{refIdx > 0 && ", "}
<span style={{ fontSize: "0.75rem" }}>
{ref.document_title}
</span>
</span>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Final Report */}
{state.final_report && (
<div
style={{
background: "white",
borderRadius: "8px",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
overflow: "hidden",
}}
>
<button
type="button"
onClick={() => toggleSection("report")}
style={{
width: "100%",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0.75rem",
background: "#edf2f7",
border: "1px solid #e2e8f0",
borderRadius: "4px",
cursor: "pointer",
fontSize: "1rem",
fontWeight: "600",
color: "#2d3748",
}}
>
<span>Final Report</span>
<span>{expandedSections.report ? "▼" : "▶"}</span>
</button>
{expandedSections.report && (
<div
style={{
padding: "1.5rem",
background: "white",
border: "1px solid #e2e8f0",
borderTop: "none",
borderRadius: "0 0 4px 4px",
}}
>
<h3
style={{
fontSize: "1.25rem",
fontWeight: "600",
marginBottom: "1rem",
color: "#2d3748",
}}
>
{state.final_report.title}
</h3>
<div style={{ marginBottom: "1.5rem" }}>
<h4
style={{
fontSize: "0.875rem",
fontWeight: "600",
color: "#718096",
marginBottom: "0.5rem",
}}
>
Executive Summary
</h4>
<div
style={{
fontSize: "0.875rem",
color: "#4a5568",
lineHeight: "1.6",
}}
>
<Markdown content={state.final_report.summary} />
</div>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<h4
style={{
fontSize: "0.875rem",
fontWeight: "600",
color: "#718096",
marginBottom: "0.5rem",
}}
>
Main Findings
</h4>
<ul
style={{
paddingLeft: "1.5rem",
fontSize: "0.875rem",
color: "#4a5568",
lineHeight: "1.6",
}}
>
{state.final_report.findings.map((finding, idx) => (
<li
key={`finding-${idx}-${finding.substring(0, 30)}`}
style={{ marginBottom: "0.5rem" }}
>
<Markdown content={finding} />
</li>
))}
</ul>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<h4
style={{
fontSize: "0.875rem",
fontWeight: "600",
color: "#718096",
marginBottom: "0.5rem",
}}
>
Conclusions
</h4>
<ul
style={{
paddingLeft: "1.5rem",
fontSize: "0.875rem",
color: "#4a5568",
lineHeight: "1.6",
}}
>
{state.final_report.conclusions.map((conclusion, idx) => (
<li
key={`conclusion-${idx}-${conclusion.substring(0, 30)}`}
style={{ marginBottom: "0.5rem" }}
>
<Markdown content={conclusion} />
</li>
))}
</ul>
</div>
<div>
<h4
style={{
fontSize: "0.875rem",
fontWeight: "600",
color: "#718096",
marginBottom: "0.5rem",
}}
>
Citations
</h4>
{state.final_report.citations &&
state.final_report.citations.length > 0 ? (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.5rem",
}}
>
{state.final_report.citations.map((citation) => (
<div
key={citation.document_uri}
style={{
padding: "0.5rem",
background: "#f7fafc",
borderRadius: "4px",
border: "1px solid #e2e8f0",
}}
>
<div
style={{
fontSize: "0.875rem",
fontWeight: "600",
color: "#2d3748",
marginBottom: "0.25rem",
}}
>
{citation.document_title}
</div>
<div
style={{
fontSize: "0.75rem",
color: "#718096",
}}
>
{citation.chunk_ids.length} chunk
{citation.chunk_ids.length !== 1 ? "s" : ""}{" "}
referenced
</div>
</div>
))}
</div>
) : (
<div
style={{
fontSize: "0.75rem",
color: "#718096",
lineHeight: "1.4",
}}
>
{state.final_report.sources?.map((source) => (
<div key={source} style={{ marginBottom: "0.25rem" }}>
{source}
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,29 @@
{
"name": "ag-ui-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"check": "biome check app components",
"format": "biome check --write app components"
},
"dependencies": {
"@ag-ui/client": "^0.0.40",
"@copilotkit/react-core": "^1.10.6",
"@copilotkit/react-ui": "^1.10.6",
"@copilotkit/runtime": "^1.10.6",
"next": "15.5.5",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@biomejs/biome": "2.2.6",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"typescript": "^5"
}
}

View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,6 @@
{
"name": "haiku-ag-ui",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}