feat(generative-ui): add AI Deep Research Agent demo

Vendors the deep-agents showcase from CopilotKit/CopilotKit
(examples/showcases/deep-agents) into generative_ui_agents/ —
self-contained Next.js + LangGraph Python app demonstrating
planning, virtual filesystem, and per-tool generative UI cards
with Tavily-powered web research.
This commit is contained in:
GeneralJerel
2026-05-18 05:23:54 -07:00
parent 80c0e55cc4
commit 1677b7bb2e
30 changed files with 20499 additions and 0 deletions
+1
View File
@@ -172,6 +172,7 @@ streamlit run travel_agent.py
* [🛠️ AI MCP App Builder](generative_ui_agents/ai-mcp-app-builder/)
* [✈️ MCP Apps Generative UI Showcase](generative_ui_agents/mcp-apps-generative-ui-showcase/)
* [🎛️ AI Shadcn Component Generator](generative_ui_agents/ai-shadcn-component-generator/)
* [🔍 AI Deep Research Agent](generative_ui_agents/ai-deep-research-agent/)
See [`generative_ui_agents/`](generative_ui_agents/) for the category overview, conventions, and how to contribute a template.
@@ -0,0 +1,8 @@
node_modules
.next
.git
.env
.env.local
.meridian
*.log
.DS_Store
@@ -0,0 +1,15 @@
# Deep Research Assistant - Environment Variables
# Backend Server Configuration
SERVER_HOST=0.0.0.0
SERVER_PORT=8123
# Frontend → Backend Connection
LANGGRAPH_DEPLOYMENT_URL=http://localhost:8123
# OpenAI API (https://platform.openai.com/api-keys)
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-5.2
# Tavily API (https://app.tavily.com/)
TAVILY_API_KEY=tvly-...
@@ -0,0 +1,61 @@
# Project-specific
.mcp.json
CLAUDE.md
.claude/
.meridian/
# Dependencies
node_modules/
.pnpm-store/
# Next.js
.next/
out/
build/
# Python
__pycache__/
*.py[cod]
*$py.class
.Python
*.so
.eggs/
*.egg-info/
.venv/
venv/
ENV/
.uv/
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# TypeScript
*.tsbuildinfo
next-env.d.ts
# Testing
coverage/
.nyc_output/
# Misc
*.log
.cache/
@@ -0,0 +1,21 @@
FROM node:20-slim
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy source files
COPY . .
# Build Next.js app
RUN npm run build
# Expose port
EXPOSE 3000
# Start server
CMD ["npm", "start"]
@@ -0,0 +1,100 @@
# AI Deep Research Agent
A deep research assistant that plans, searches the web, writes to a virtual filesystem, and renders each tool call as a live card in a workspace pane. Built with [CopilotKit](https://github.com/CopilotKit/CopilotKit), [Deep Agents](https://docs.copilotkit.ai/integrations/langgraph/deep-agents), [AG-UI](https://github.com/ag-ui-protocol/ag-ui), and [Tavily](https://www.tavily.com/) on top of Next.js + LangGraph (Python).
https://github.com/user-attachments/assets/68d5729f-91f9-4fd9-a579-cd1a8f4aad8d
**Gen UI concept — tool-rendered components with a sidecar workspace.** The Deep Agent emits four tools — `write_todos`, `write_file`, `read_file`, and `research` — and each one renders inline as a status card in the chat while updating a parallel workspace pane (plan, files, expandable tool results). Local React state mirrors the agent's filesystem via `useDefaultTool` rather than `useCoAgent`, sidestepping a Python `Dict` ↔ TypeScript `Array` type mismatch.
## Prerequisites
- Node.js 18+
- Python 3.12+
- [OpenAI API Key](https://platform.openai.com/api-keys)
- [Tavily API Key](https://app.tavily.com/home)
- [uv](https://docs.astral.sh/uv/) (or pip) for Python deps
## Getting Started
1. Install Node dependencies:
```bash
npm install
```
2. Install Python dependencies for the agent:
```bash
cd agent
uv venv && source .venv/bin/activate
uv pip install -e .
cd ..
```
Or with pip:
```bash
cd agent
python -m venv .venv && source .venv/bin/activate
pip install -e .
cd ..
```
3. Copy `.env.example` to `.env` in both the root and `agent/` directories, then fill in `OPENAI_API_KEY` and `TAVILY_API_KEY`.
4. Start the agent (terminal 1):
```bash
cd agent
uv run python main.py
```
5. Start the frontend (terminal 2):
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) and ask the assistant to research any topic.
## Architecture
```
[User asks research question]
Next.js Frontend (CopilotChat + Workspace)
CopilotKit Runtime → LangGraphHttpAgent
Python Backend (FastAPI + AG-UI)
Deep Agent (research_assistant)
├── write_todos (planning, built-in)
├── write_file (filesystem, built-in)
├── read_file (filesystem, built-in)
└── research(query)
└── internal Deep Agent [thread-isolated]
└── internet_search (Tavily)
```
## Environment Variables
| Variable | Required | Default | Description |
| -------------------------- | -------- | ----------------------- | --------------------------------------------------- |
| `OPENAI_API_KEY` | Yes | - | [Get API key](https://platform.openai.com/api-keys) |
| `TAVILY_API_KEY` | Yes | - | [Get API key](https://app.tavily.com/home) |
| `OPENAI_MODEL` | No | `gpt-5.2` | Model to use (gpt-5.2, gpt-5, etc.) |
| `LANGGRAPH_DEPLOYMENT_URL` | No | `http://localhost:8123` | Backend URL |
| `SERVER_HOST` | No | `0.0.0.0` | Backend host |
| `SERVER_PORT` | No | `8123` | Backend port |
## Learn more
- [Deep Agents documentation](https://docs.copilotkit.ai/integrations/langgraph/deep-agents)
- [Building Frontends for Deep Agents](https://www.copilotkit.ai/blog/how-to-build-a-frontend-for-langchain-deep-agents-with-copilotkit)
- [CopilotKit documentation](https://docs.copilotkit.ai)
- [Tavily documentation](https://docs.tavily.com/welcome)
## License
Upstream license applies — see [`CopilotKit/CopilotKit`](https://github.com/CopilotKit/CopilotKit).
@@ -0,0 +1,8 @@
.venv
__pycache__
*.pyc
.git
.env
*.log
.DS_Store
deep_research_agent.egg-info
@@ -0,0 +1,96 @@
"""
Deep Research Assistant Agent
A Deep Agents-powered research assistant that demonstrates CopilotKit's
planning, filesystem, and subagent capabilities using Tavily for web research.
"""
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from deepagents import create_deep_agent
from langgraph.checkpoint.memory import MemorySaver
from copilotkit import CopilotKitMiddleware
from tools import research
load_dotenv()
# Main agent system prompt - coordinates research and synthesizes findings
MAIN_SYSTEM_PROMPT = """You are a Deep Research Assistant, an expert at planning and
executing comprehensive research on any topic.
Hard rules (ALWAYS follow):
- NEVER output raw JSON, data structures, or code blocks in your messages
- Communicate with the user only in natural, readable prose
- When you receive data from research, synthesize it into insights
Your workflow:
1. PLAN: Create a research plan using write_todos with clear, actionable steps
2. RESEARCH: Use research(query) tool to investigate each topic
3. SYNTHESIZE: Write a final report to /reports/final_report.md using write_file
Important guidelines:
- Always start by creating a research plan with write_todos
- Call research() for each distinct research question
- The research tool returns prose summaries of findings
- You write all files - compile findings into a comprehensive report
- Update todos as you complete each step
Example workflow:
1. write_todos(["Research topic A", "Research topic B", "Synthesize findings"])
2. research("Find information about topic A") -> receives prose summary
3. research("Find information about topic B") -> receives prose summary
4. write_file("/reports/final_report.md", "# Research Report\n\n...")
Always maintain a professional, comprehensive research style."""
def build_agent():
"""Build the Deep Research Agent with CopilotKit integration.
Creates a main research coordinator agent with a researcher subagent.
Uses CopilotKitMiddleware for frontend state sync and generative UI.
Returns:
Compiled LangGraph StateGraph configured for research tasks
"""
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
raise RuntimeError("Missing OPENAI_API_KEY environment variable")
# Check for Tavily API key
tavily_key = os.environ.get("TAVILY_API_KEY")
if not tavily_key:
raise RuntimeError("Missing TAVILY_API_KEY environment variable")
# Initialize LLM - use model from env or default to gpt-5.2
model_name = os.environ.get("OPENAI_MODEL", "gpt-5.2")
llm = ChatOpenAI(
model=model_name,
temperature=0.7,
api_key=api_key,
)
# Main agent gets research tool plus built-in Deep Agents tools
# (write_todos, read_file, write_file)
# The research tool wraps an internal Deep Agent that runs via .invoke()
# so its text doesn't stream to the frontend
main_tools = [research]
# Create the Deep Agent with CopilotKit middleware
# No subagents - research() tool handles web search internally
agent_graph = create_deep_agent(
model=llm,
system_prompt=MAIN_SYSTEM_PROMPT,
tools=main_tools,
middleware=[CopilotKitMiddleware()],
checkpointer=MemorySaver(),
)
print(f"[AGENT] Deep Research Agent created with model={model_name}")
print(f"[AGENT] Main tools: {[t.name for t in main_tools]}")
# Configure recursion limit for complex research tasks
return agent_graph.with_config({"recursion_limit": 100})
@@ -0,0 +1,99 @@
"""
Deep Research Assistant - FastAPI Server
Serves the Deep Research Agent via AG-UI protocol for CopilotKit integration.
The agent uses Tavily for web research and Deep Agents for planning and filesystem operations.
"""
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from dotenv import load_dotenv
from ag_ui_langgraph import add_langgraph_fastapi_endpoint
from copilotkit import LangGraphAGUIAgent
from copilotkit.langgraph import copilotkit_customize_config
from agent import build_agent
load_dotenv()
app = FastAPI(
title="Deep Research Assistant",
description="A research assistant powered by Deep Agents and CopilotKit",
version="1.0.0",
)
# Enable CORS for frontend communication
# Using "*" for demo purposes - allows any origin including localhost and Railway deployments
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
def health():
"""Health check endpoint for monitoring and Railway deployments"""
return {"status": "ok", "service": "deep-research-agent", "version": "1.0.0"}
# Build and register the Deep Research Agent
try:
agent_graph = build_agent()
# Configure which tool calls to emit to the frontend
# Only emit main agent tools - suppress internal tools (internet_search from research subagent)
# This prevents subagent tool calls from appearing as JSON noise in the chat
agui_config = copilotkit_customize_config(
emit_tool_calls=[
"research",
"write_todos",
"write_file",
"read_file",
"edit_file",
]
)
# Add recursion limit for complex research tasks (6+ research calls + file operations)
agui_config["recursion_limit"] = 100
# Add AG-UI endpoint at root path for CopilotKit frontend
add_langgraph_fastapi_endpoint(
app=app,
agent=LangGraphAGUIAgent(
name="research_assistant",
description="A deep research assistant that plans, searches, and synthesizes research reports",
graph=agent_graph,
config=agui_config,
),
path="/",
)
print("[SERVER] Deep Research Agent registered at /")
except Exception as e:
print(f"[ERROR] Failed to build agent: {e}")
raise
def main():
"""Run the server with uvicorn"""
import uvicorn
host = os.getenv("SERVER_HOST", "0.0.0.0")
port = int(os.getenv("SERVER_PORT", "8123"))
print(f"[SERVER] Starting on {host}:{port}")
uvicorn.run(
"main:app",
host=host,
port=port,
reload=True,
log_level="info",
)
if __name__ == "__main__":
main()
@@ -0,0 +1,20 @@
[project]
name = "deep-research-agent"
version = "0.1.0"
description = "Deep Research Assistant - A CopilotKit Deep Agents demo"
requires-python = ">=3.12"
dependencies = [
"ag-ui-langgraph>=0.0.23",
"copilotkit>=0.1.76",
"deepagents>=0.3.5",
"fastapi>=0.115.14",
"langchain>=1.2.4",
"langchain-openai>=1.1.7",
"python-dotenv>=1.2.1",
"tavily-python>=0.3.0",
"uvicorn[standard]>=0.40.0",
]
[tool.setuptools]
py-modules = ["agent", "main", "tools"]
@@ -0,0 +1,9 @@
[build]
builder = "nixpacks"
[deploy]
startCommand = "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}"
healthcheckPath = "/health"
healthcheckTimeout = 300
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 5
@@ -0,0 +1,179 @@
"""
Tavily-based Tools for Deep Research Agent
Provides web search with content using the Tavily API.
The search returns full page content, eliminating the need for separate scraping.
The research() tool wraps an internal Deep Agent that runs in a separate thread
to prevent subagent text from leaking to the frontend via LangChain callback propagation.
"""
import os
from typing import Any
from concurrent.futures import ThreadPoolExecutor
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage
from tavily import TavilyClient
def _do_internet_search(query: str, max_results: int = 5) -> list[dict[str, Any]]:
"""Core search logic - callable as regular function.
Args:
query: The search query string
max_results: Maximum number of results to return (default: 5)
Returns:
List of dicts with url, title, and content for each result
"""
print(f"[TOOL] internet_search: query='{query}', max_results={max_results}")
tavily_key = os.environ.get("TAVILY_API_KEY")
if not tavily_key:
raise RuntimeError("TAVILY_API_KEY not set")
try:
client = TavilyClient(api_key=tavily_key)
results = client.search(
query=query,
max_results=max_results,
include_raw_content=False, # Disable raw content for performance
topic="general",
)
# Format results for agent consumption
formatted_results = []
for r in results.get("results", []):
formatted_results.append(
{
"url": r.get("url", ""),
"title": r.get("title", ""),
"content": (r.get("content") or "")[
:3000
], # Truncate to 3000 chars
}
)
print(f"[TOOL] internet_search: found {len(formatted_results)} results")
return formatted_results
except Exception as e:
print(f"[TOOL] internet_search error: {e}")
return [{"error": str(e)}]
@tool
def internet_search(query: str, max_results: int = 5) -> list[dict[str, Any]]:
"""Search the web and return results with content.
Use this tool to find relevant web pages about a topic.
Returns search results including the page content for analysis.
Args:
query: The search query string
max_results: Maximum number of results to return (default: 5)
Returns:
List of dicts with url, title, and content for each result
"""
return _do_internet_search(query, max_results)
@tool
def research(query: str) -> dict:
"""
Research a topic using web search. Returns structured data with sources.
This tool creates an internal Deep Agent that runs in a SEPARATE THREAD to prevent
LangChain callback propagation. The thread has isolated execution context, so the
internal agent's events don't leak to the parent's astream_events() stream.
Args:
query: The research query/topic to investigate
Returns:
dict: {
"summary": str - Prose summary of findings,
"sources": list[dict] - [{url, title, content, status}, ...]
}
"""
print(f"[TOOL] research: query='{query}' (using thread isolation)")
from deepagents import create_deep_agent
from langchain_openai import ChatOpenAI
def _run_research_isolated():
"""
Runs in separate thread with no inherited LangChain context.
This breaks callback propagation at the OS level.
"""
# Capture internet_search results
search_results = []
# Wrapper to capture results while passing through to agent
def internet_search_tracked(query: str, max_results: int = 5):
"""Search the web and return results with content.
Args:
query: The search query string
max_results: Maximum number of results to return (default: 5)
Returns:
List of dicts with url, title, and content for each result
"""
results = _do_internet_search(query, max_results)
search_results.extend(results)
return results
model_name = os.environ.get("OPENAI_MODEL", "gpt-5.2")
llm = ChatOpenAI(
model=model_name,
temperature=0.7,
api_key=os.environ.get("OPENAI_API_KEY"),
)
# System prompt for the internal researcher
researcher_prompt = """You are a Research Specialist.
Use internet_search to find information. Return a prose summary of findings.
Rules:
- Call internet_search ONCE with a focused query
- Analyze the returned content
- Return a brief summary (2-3 sentences) of key findings
- No JSON, no code blocks, just prose"""
research_agent = create_deep_agent(
model=llm,
system_prompt=researcher_prompt,
tools=[internet_search_tracked], # Use tracked version
# No middleware - this runs in isolated thread
)
# Run in isolated thread context - no callback inheritance possible
result = research_agent.invoke({"messages": [HumanMessage(content=query)]})
summary = result["messages"][-1].content
# Format sources for frontend
sources = [
{
"url": r["url"],
"title": r.get("title", ""),
"content": r.get("content", "")[:3000], # Include content preview
"status": "found",
}
for r in search_results
if "url" in r and not r.get("error")
]
return {"summary": summary, "sources": sources}
# Run in thread pool to isolate from parent async context
# This blocks the tool execution until research completes, which is acceptable
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(_run_research_isolated)
result = future.result() # Blocks until complete
print(f"[TOOL] research: completed with {len(result['sources'])} sources")
return result
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Wrapper that runs claude and handles auto-restart signals
# Usage: meridian-wrapper [claude args...]
set -euo pipefail
# Find project root (where .meridian exists)
find_project_root() {
local dir="$PWD"
while [[ "$dir" != "/" ]]; do
if [[ -d "$dir/.meridian" ]]; then
echo "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
echo "$PWD" # Fallback to current dir
}
PROJECT_ROOT="$(find_project_root)"
SIGNAL_FILE="$PROJECT_ROOT/.meridian/.state/restart-signal"
# Clean any stale signal file on startup
rm -f "$SIGNAL_FILE" 2>/dev/null || true
while true; do
# Run claude with all passed arguments
claude "$@" || EXIT_CODE=$?
EXIT_CODE=${EXIT_CODE:-0}
# Check for restart signal
if [[ -f "$SIGNAL_FILE" ]]; then
# Read the initial prompt from signal file
INITIAL_PROMPT=$(cat "$SIGNAL_FILE")
rm -f "$SIGNAL_FILE"
echo ""
echo "🔄 Meridian: Restarting session with prompt: \"$INITIAL_PROMPT\""
echo ""
# Small delay to ensure clean handoff
sleep 0.5
# Restart claude with the prompt as argument
# Note: claude accepts initial prompt as positional argument
set -- "$INITIAL_PROMPT" # Replace args with just the prompt
continue # Loop back to run claude with new args
else
# No restart signal - exit wrapper with claude's exit code
exit $EXIT_CODE
fi
done
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
{
"name": "deep-research-assistant",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@copilotkit/react-core": "^1.51.0",
"@copilotkit/react-ui": "^1.51.0",
"@copilotkit/runtime": "^1.51.0",
"lucide-react": "^0.562.0",
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^9.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"patch-package": "^8.0.1",
"tailwindcss": "^4",
"typescript": "^5"
}
}
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0ec9c61cac923697b1c3e9452458d24d4a75cbdcdef05194d94a35eb68f3d59a
size 215406
@@ -0,0 +1,12 @@
{
"$schema": "https://railway.app/railway.schema.json",
"build": {
"builder": "DOCKERFILE",
"dockerfilePath": "Dockerfile"
},
"deploy": {
"startCommand": "npm start",
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 5
}
}
@@ -0,0 +1,15 @@
[build]
builder = "dockerfile"
dockerfilePath = "Dockerfile"
watchPatterns = [
"src/**",
"public/**",
"package.json",
"Dockerfile",
"next.config.ts",
]
[deploy]
startCommand = "npm start"
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 5
@@ -0,0 +1,29 @@
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { LangGraphHttpAgent } from "@copilotkit/runtime/langgraph";
import { NextRequest } from "next/server";
// Empty adapter since the LLM is handled by the remote agent
const serviceAdapter = new ExperimentalEmptyAdapter();
// Configure CopilotKit runtime with the Deep Agents backend
const runtime = new CopilotRuntime({
agents: {
research_assistant: new LangGraphHttpAgent({
url: process.env.LANGGRAPH_DEPLOYMENT_URL || "http://localhost:8123",
}),
},
});
export const POST = async (req: NextRequest) => {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter,
endpoint: "/api/copilotkit",
});
return handleRequest(req);
};
@@ -0,0 +1,589 @@
/* Inter + JetBrains Mono - Refined typography system */
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap");
@import "tailwindcss";
/* Typography plugin for markdown rendering */
@plugin "@tailwindcss/typography";
/* =============================================================================
DESIGN SYSTEM - CopilotKit Palette
============================================================================= */
:root {
/* Background & Surfaces */
--color-background: #fafaf9;
--color-surface: #f5f5f4;
--color-surface-light: #fafaf9;
--color-surface-elevated: #ffffff;
--color-container: #ffffff;
/* Text Hierarchy */
--color-text-primary: #1c1917;
--color-text-secondary: #57534e;
--color-text-tertiary: #a8a29e;
/* Accent Color (Deep Amber) */
--color-accent: #d97706;
--color-accent-light: #f59e0b;
--color-accent-dark: #92400e;
/* Legacy color names for compatibility */
--color-lilac: var(--color-accent);
--color-lilac-light: var(--color-accent-light);
--color-lilac-dark: var(--color-accent-dark);
--color-mint: var(--color-success);
--color-mint-light: #10b981;
--color-mint-dark: var(--color-success);
/* Status Colors */
--color-success: #15803d;
--color-error: #b91c1c;
--color-warning: #d97706;
/* Borders */
--color-border: #e7e5e4;
--color-border-light: #f5f5f4;
--color-border-subtle: #f5f5f4;
--color-border-glass: rgba(231, 229, 228, 0.4);
/* Glassmorphism */
--color-glass: rgba(255, 255, 255, 0.85);
--color-glass-subtle: rgba(255, 255, 255, 0.6);
--color-glass-dark: rgba(255, 255, 255, 0.95);
--color-glass-elevated: rgba(255, 255, 255, 0.95);
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md:
0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -1px rgba(0, 0, 0, 0.04);
--shadow-lg:
0 10px 25px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.03);
--shadow-glass: 0 4px 30px rgba(0, 0, 0, 0.1);
/* Spacing */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
--space-20: 80px;
/* Radii */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 24px;
/* Typography */
--font-display: "Inter", system-ui, sans-serif;
--font-body: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Courier New", monospace;
--font-family: "Inter", system-ui, sans-serif;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.375rem;
--text-2xl: 1.75rem;
--text-3xl: 2.25rem;
--font-size-xs: 12px;
--font-size-sm: 14px;
--font-size-base: 16px;
--font-size-lg: 18px;
--font-size-xl: 20px;
--font-size-2xl: 24px;
--font-size-3xl: 32px;
--font-size-4xl: 40px;
--font-light: 300;
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
--font-extrabold: 800;
color-scheme: light;
}
@theme inline {
--color-background: var(--color-surface-light);
--color-foreground: var(--color-text-primary);
--font-sans: var(--font-family);
}
/* =============================================================================
BASE STYLES
============================================================================= */
body {
font-family: var(--font-family);
background: var(--color-surface-light);
color: var(--color-text-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* =============================================================================
ABSTRACT SHAPES BACKGROUND
============================================================================= */
.abstract-bg {
position: fixed;
inset: 0;
overflow: hidden;
z-index: 0;
background: linear-gradient(
135deg,
var(--color-surface-light) 0%,
var(--color-surface) 100%
);
}
.abstract-bg::before,
.abstract-bg::after {
content: "";
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.6;
}
.abstract-bg::before {
width: 600px;
height: 600px;
background: radial-gradient(
circle,
rgba(217, 119, 6, 0.15),
rgba(217, 119, 6, 0.08),
transparent 70%
);
top: -200px;
right: -100px;
animation: blob1 25s ease-in-out infinite;
}
.abstract-bg::after {
width: 500px;
height: 500px;
background: radial-gradient(
circle,
rgba(168, 162, 158, 0.12),
rgba(168, 162, 158, 0.06),
transparent 70%
);
bottom: -150px;
left: -100px;
animation: blob2 30s ease-in-out infinite;
}
.blob-3 {
position: absolute;
width: 400px;
height: 400px;
background: radial-gradient(
circle,
rgba(217, 119, 6, 0.1),
rgba(231, 229, 228, 0.08),
transparent 70%
);
border-radius: 50%;
filter: blur(100px);
opacity: 0.4;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: blob3 35s ease-in-out infinite;
}
@keyframes blob1 {
0%,
100% {
transform: translate(0, 0) scale(1);
}
25% {
transform: translate(-30px, 50px) scale(1.1);
}
50% {
transform: translate(20px, -30px) scale(0.95);
}
75% {
transform: translate(40px, 20px) scale(1.05);
}
}
@keyframes blob2 {
0%,
100% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(50px, -40px) scale(1.1);
}
66% {
transform: translate(-30px, 30px) scale(0.9);
}
}
@keyframes blob3 {
0%,
100% {
transform: translate(-50%, -50%) scale(1);
}
25% {
transform: translate(-45%, -55%) scale(1.15);
}
50% {
transform: translate(-55%, -45%) scale(0.9);
}
75% {
transform: translate(-48%, -52%) scale(1.1);
}
}
/* =============================================================================
GLASSMORPHISM COMPONENTS
============================================================================= */
.glass {
background: var(--color-glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--color-border-glass);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-glass);
}
.glass-subtle {
background: var(--color-glass-subtle);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--color-border-glass);
border-radius: var(--radius-lg);
}
.glass-card {
background: var(--color-glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--color-border-glass);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-glass);
padding: var(--space-6);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.glass-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.12);
}
/* =============================================================================
WORKSPACE PANEL (Deep Research specific)
============================================================================= */
.workspace-panel {
background: var(--color-glass-dark);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
height: 100%;
overflow-y: auto;
}
.workspace-section {
background: var(--color-glass);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--color-border-glass);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
overflow: hidden;
}
.workspace-section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
cursor: pointer;
transition: background 0.2s ease;
}
.workspace-section-header:hover {
background: var(--color-glass-subtle);
}
.workspace-section-content {
padding: var(--space-4);
padding-top: 0;
}
/* Todo items */
.todo-item {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3);
border-radius: var(--radius-md);
transition: background 0.2s ease;
}
.todo-item:hover {
background: var(--color-glass-subtle);
}
.todo-item-completed {
text-decoration: line-through;
color: var(--color-text-tertiary);
}
.todo-item-pending {
color: var(--color-text-secondary);
}
.todo-item-inprogress {
color: var(--color-text-primary);
}
/* Status indicators */
.status-completed {
color: var(--color-success);
}
.status-pending {
color: var(--color-text-tertiary);
}
.status-inprogress {
color: var(--color-accent-dark);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* File items */
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3);
border-radius: var(--radius-md);
transition: background 0.2s ease;
cursor: pointer;
}
.file-item:hover {
background: var(--color-glass-subtle);
}
.file-item-icon {
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
background: linear-gradient(
135deg,
var(--color-accent),
var(--color-accent-dark)
);
display: flex;
align-items: center;
justify-content: center;
color: white;
}
/* Subagent indicator */
.subagent-indicator {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4);
background: linear-gradient(
135deg,
var(--color-accent-light),
rgba(231, 229, 228, 0.5)
);
border-radius: var(--radius-lg);
}
.subagent-indicator-active {
animation: shimmer 2s ease-in-out infinite;
}
@keyframes shimmer {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.subagent-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(
135deg,
var(--color-accent-dark),
var(--color-accent)
);
display: flex;
align-items: center;
justify-content: center;
color: white;
}
/* =============================================================================
COPILOTKIT SIDEBAR OVERRIDES
============================================================================= */
[data-copilotkit-sidebar] {
--copilot-kit-background-color: rgba(250, 250, 249, 0.95) !important;
--copilot-kit-secondary-color: rgba(255, 255, 255, 0.7) !important;
--copilot-kit-primary-color: var(--color-accent) !important;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-left: 1px solid var(--color-border-glass) !important;
}
[data-copilotkit-sidebar] .copilotkit-message {
font-family: var(--font-family);
}
[data-copilotkit-sidebar] textarea,
[data-copilotkit-sidebar] input {
font-family: var(--font-family);
border-radius: var(--radius-lg);
}
/* =============================================================================
UTILITY CLASSES
============================================================================= */
.text-gradient {
background: linear-gradient(
135deg,
var(--color-accent-dark),
var(--color-accent)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.border-gradient {
border: 2px solid transparent;
background:
linear-gradient(var(--color-container), var(--color-container)) padding-box,
linear-gradient(135deg, var(--color-accent), var(--color-accent-light))
border-box;
}
/* Empty state styling */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-8);
color: var(--color-text-tertiary);
text-align: center;
}
.empty-state-icon {
width: 48px;
height: 48px;
margin-bottom: var(--space-4);
opacity: 0.5;
}
/* Error state styling for failed sources */
.source-failed {
opacity: 0.6;
}
.source-failed .source-indicator {
color: var(--color-error);
}
/* =============================================================================
ANIMATIONS - List items and transitions
============================================================================= */
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeSlideIn {
animation: fadeSlideIn 0.3s ease-out forwards;
}
/* Fade in animation for empty states */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Rotate animation for loading states */
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Slow spin for in-progress tool icons */
@keyframes spin-slow {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin-slow {
animation: spin-slow 3s linear infinite;
}
/* Hover scale for interactive elements */
.hover-scale {
transition: transform 0.2s ease;
}
.hover-scale:hover {
transform: scale(1.02);
}
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { CopilotKit } from "@copilotkit/react-core";
import "./globals.css";
import "@copilotkit/react-ui/styles.css";
export const metadata: Metadata = {
title: "Deep Research Assistant | CopilotKit Deep Agents Demo",
description:
"A research assistant powered by Deep Agents and CopilotKit - demonstrating planning, memory, subagents, and generative UI",
openGraph: {
title: "Deep Research Assistant",
description: "A research assistant powered by Deep Agents and CopilotKit",
images: ["/og-image.png"],
},
twitter: {
card: "summary_large_image",
title: "Deep Research Assistant",
description: "A research assistant powered by Deep Agents and CopilotKit",
images: ["/og-image.png"],
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="antialiased">
<CopilotKit runtimeUrl="/api/copilotkit" agent="research_assistant">
{children}
</CopilotKit>
</body>
</html>
);
}
@@ -0,0 +1,159 @@
"use client";
import { useState, useRef } from "react";
import { CopilotChat } from "@copilotkit/react-ui";
import { useDefaultTool } from "@copilotkit/react-core";
import { Workspace } from "@/components/Workspace";
import { ResearchState, INITIAL_STATE, Todo } from "@/types/research";
import { ToolCard } from "@/components/ToolCard";
export default function Page() {
const [state, setState] = useState<ResearchState>(INITIAL_STATE);
const processedKeysRef = useRef<Set<string>>(new Set());
useDefaultTool({
render: (props) => {
const { name, status, args, result } = props;
// Prevent duplicate processing on re-renders
if (status === "complete") {
const resultStr = result ? JSON.stringify(result) : "";
const resultHash = resultStr
? `${resultStr.length}-${resultStr.slice(0, 100)}`
: "";
const key = `${name}-${JSON.stringify(args)}-${resultHash}`;
if (processedKeysRef.current.has(key)) {
return <ToolCard {...props} />;
}
processedKeysRef.current.add(key);
}
// Handle research tool - track summary and sources
if (name === "research" && status === "complete" && result) {
const researchResult = result as {
summary: string;
sources: Array<{
url: string;
title: string;
content?: string;
status: "found" | "scraped" | "failed";
}>;
};
// Track sources in state
if (researchResult.sources && researchResult.sources.length > 0) {
queueMicrotask(() =>
setState((prev) => ({
...prev,
sources: [...prev.sources, ...researchResult.sources],
})),
);
}
console.log(
`[UI] Research completed: ${researchResult.sources?.length || 0} sources found`,
);
}
// Handle write_todos tool
if (name === "write_todos" && status === "complete" && args?.todos) {
const todosWithIds = (
args.todos as Array<{ id?: string; content: string; status: string }>
).map((todo, index) => ({
...todo,
id: todo.id || `todo-${Date.now()}-${index}`,
}));
queueMicrotask(() =>
setState((prev) => ({ ...prev, todos: todosWithIds as Todo[] })),
);
}
// Handle write_file tool
// Deep Agents uses file_path (not path) as the parameter name
if (name === "write_file" && status === "complete" && args?.file_path) {
queueMicrotask(() =>
setState((prev) => ({
...prev,
files: [
...prev.files,
{
path: args.file_path as string,
content: args.content as string,
createdAt: new Date().toISOString(),
},
],
})),
);
}
return <ToolCard {...props} />;
},
});
return (
<div className="relative min-h-screen">
{/* Animated background */}
<div className="abstract-bg">
<div className="blob-3" />
</div>
{/* Main content */}
<main className="relative z-10 h-screen flex overflow-hidden">
{/* Chat panel - left side (38%) */}
<div className="w-[38%] h-full border-r border-[var(--color-border-glass)] bg-[var(--color-glass-dark)] backdrop-blur-xl overflow-hidden">
<div className="h-full flex flex-col">
{/* Header */}
<header
style={{ padding: "var(--space-8)" }}
className="border-b border-[var(--color-border-glass)]"
>
<h1
style={{
fontSize: "var(--text-3xl)",
fontWeight: "var(--font-extrabold)",
fontFamily: "var(--font-display)",
fontOpticalSizing: "auto",
}}
className="text-gradient"
>
Deep Research Assistant
</h1>
<p
style={{
fontSize: "var(--text-sm)",
color: "var(--color-text-secondary)",
marginTop: "var(--space-1)",
}}
>
Ask me to research any topic
</p>
</header>
<div
style={{
flex: 1,
minHeight: 0,
overflow: "hidden",
padding: "var(--space-6)",
}}
>
<CopilotChat
className="h-full"
labels={{
title: "Deep Research Assistant",
initial: "What topic would you like me to research?",
placeholder: "Ask me to research any topic...",
}}
/>
</div>
</div>
</div>
{/* Workspace panel - right side (62%) */}
<div className="w-[62%] h-full overflow-hidden">
<Workspace state={state} />
</div>
</main>
</div>
);
}
@@ -0,0 +1,175 @@
"use client";
import { useEffect, useCallback } from "react";
import ReactMarkdown from "react-markdown";
import { X, Download, FileText } from "lucide-react";
import type { ResearchFile } from "@/types/research";
/**
* FileViewerModal - Modal for viewing file content with markdown rendering.
*
* Features:
* - Markdown rendering via react-markdown with typography styles
* - Download button to save file content
* - Closes on backdrop click, X button, or Escape key
* - Responsive sizing with scrollable content
*/
interface FileViewerModalProps {
file: ResearchFile | null;
onClose: () => void;
}
export function FileViewerModal({ file, onClose }: FileViewerModalProps) {
// Handle Escape key to close modal
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
},
[onClose],
);
useEffect(() => {
if (file) {
document.addEventListener("keydown", handleKeyDown);
// Prevent body scroll when modal is open
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "";
};
}, [file, handleKeyDown]);
// Don't render if no file selected
if (!file) return null;
// Extract filename from path
const filename = file.path.split("/").pop() || file.path;
// Download file content
const handleDownload = () => {
const blob = new Blob([file.content], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop with blur */}
<div
className="absolute inset-0 bg-black/30 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal container */}
<div
className="relative max-w-3xl w-full max-h-[85vh] flex flex-col"
style={{
background: "var(--color-glass-elevated)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
padding: 0,
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border-glass)",
boxShadow: "0 4px 30px rgba(0, 0, 0, 0.1)",
}}
role="dialog"
aria-modal="true"
aria-labelledby="file-viewer-title"
>
{/* Header */}
<div
style={{
padding:
"var(--space-6) var(--space-6) var(--space-4) var(--space-6)",
}}
className="flex items-center justify-between border-b border-[var(--color-border-glass)]"
>
<div className="flex items-center gap-3">
<div
style={{
background:
"linear-gradient(135deg, var(--color-accent) 0%, var(--color-accent-dark) 100%)",
padding: "var(--space-3)",
borderRadius: "var(--radius-md)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<FileText
style={{ width: "20px", height: "20px", color: "white" }}
/>
</div>
<h2
id="file-viewer-title"
style={{
fontSize: "var(--text-2xl)",
fontWeight: "var(--font-bold)",
fontFamily: "var(--font-display)",
color: "var(--color-text-primary)",
}}
className="truncate max-w-md"
>
{filename}
</h2>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleDownload}
className="p-2 hover:bg-[var(--color-glass-subtle)] rounded-lg transition-colors"
aria-label="Download file"
title="Download file"
>
<Download className="w-5 h-5 text-[var(--color-text-secondary)]" />
</button>
<button
onClick={onClose}
className="p-2 hover:bg-[var(--color-glass-subtle)] rounded-lg transition-colors"
aria-label="Close modal"
title="Close (Escape)"
>
<X className="w-5 h-5 text-[var(--color-text-secondary)]" />
</button>
</div>
</div>
{/* Scrollable content with markdown rendering */}
<div
className="flex-1 overflow-y-auto"
style={{ padding: "var(--space-8)" }}
>
<div className="prose prose-sm prose-slate max-w-none">
<ReactMarkdown>{file.content}</ReactMarkdown>
</div>
</div>
{/* Footer with file path */}
<div
style={{ padding: "var(--space-3) var(--space-6)" }}
className="border-t border-[var(--color-border-glass)]"
>
<code
style={{
fontFamily: "var(--font-mono)",
fontSize: "var(--text-sm)",
color: "var(--color-text-tertiary)",
}}
>
{file.path}
</code>
</div>
</div>
</div>
);
}
@@ -0,0 +1,435 @@
"use client";
import { useState } from "react";
import {
ChevronDown,
Pencil,
ClipboardList,
Search,
Save,
BookOpen,
Check,
} from "lucide-react";
/**
* ToolCard - Generative UI for tool call rendering in chat.
*
* Two rendering modes:
* - SpecializedToolCard: Emoji-based cards for known tools with result previews
* - DefaultToolCard: Generic JSON display for unknown tools
*
* Result structures expected from backend:
* - internet_search: Array<{url, title, content, raw_content}>
* - write_todos: { todos: Array<{id, content, status}> }
* - write_file: just args (path, content) - result is confirmation
* - task: completion message
*/
interface ToolCardProps {
name: string;
status: "inProgress" | "executing" | "complete";
args: Record<string, unknown>;
result?: unknown;
}
// Tool configuration mapping
const TOOL_CONFIG: Record<
string,
{
icon: React.ComponentType<{
size?: number;
strokeWidth?: number;
className?: string;
style?: React.CSSProperties;
}>;
getDisplayText: (args: Record<string, unknown>) => string;
getResultSummary?: (
result: unknown,
args: Record<string, unknown>,
) => string | null;
}
> = {
write_todos: {
icon: Pencil,
getDisplayText: () => "Updating research plan...",
// Args contains the todos array (result is a Command with ToolMessage string)
getResultSummary: (result, args) => {
const todos = (args as { todos?: unknown[] })?.todos;
if (Array.isArray(todos)) {
return `${todos.length} todo${todos.length !== 1 ? "s" : ""} updated`;
}
return null;
},
},
read_todos: {
icon: ClipboardList,
getDisplayText: () => "Checking research plan...",
getResultSummary: (result) => {
const todos = (result as { todos?: unknown[] })?.todos;
if (Array.isArray(todos)) {
return `${todos.length} todo${todos.length !== 1 ? "s" : ""} found`;
}
return null;
},
},
research: {
icon: Search,
getDisplayText: (args) =>
`Researching: ${((args.query as string) || "...").slice(0, 50)}${(args.query as string)?.length > 50 ? "..." : ""}`,
// Result is now a dict with summary and sources
getResultSummary: (result) => {
if (result && typeof result === "object" && "sources" in result) {
const { sources } = result as { summary: string; sources: unknown[] };
return `Found ${sources.length} source${sources.length !== 1 ? "s" : ""}`;
}
return "Research complete";
},
},
write_file: {
icon: Save,
getDisplayText: (args) => {
const path = args.path as string | undefined;
const filename =
path?.split("/").pop() || (args.filename as string | undefined);
return `Writing: ${filename || "file"}`;
},
// Show first line preview from args (content is in args, not result)
getResultSummary: (_result, args) => {
const content = args.content as string | undefined;
if (content) {
const firstLine = content.split("\n")[0].slice(0, 50);
return firstLine + (content.length > 50 ? "..." : "");
}
return "File written";
},
},
read_file: {
icon: BookOpen,
getDisplayText: (args) => {
const path = args.path as string | undefined;
const filename =
path?.split("/").pop() || (args.filename as string | undefined);
return `Reading: ${filename || "file"}`;
},
getResultSummary: (result) => {
const content = (result as { content?: string })?.content;
if (content && typeof content === "string") {
const preview = content.slice(0, 50);
return preview + (content.length > 50 ? "..." : "");
}
return null;
},
},
};
export function ToolCard({ name, status, args, result }: ToolCardProps) {
const config = TOOL_CONFIG[name];
if (config) {
return (
<SpecializedToolCard
name={name}
status={status}
args={args}
result={result}
config={config}
/>
);
}
return (
<DefaultToolCard name={name} status={status} args={args} result={result} />
);
}
interface SpecializedToolCardProps extends ToolCardProps {
config: {
icon: React.ComponentType<{
size?: number;
strokeWidth?: number;
className?: string;
style?: React.CSSProperties;
}>;
getDisplayText: (args: Record<string, unknown>) => string;
getResultSummary?: (
result: unknown,
args: Record<string, unknown>,
) => string | null;
};
}
function SpecializedToolCard({
name,
status,
args,
result,
config,
}: SpecializedToolCardProps) {
const [expanded, setExpanded] = useState(false);
const isComplete = status === "complete";
const isExecuting = status === "inProgress" || status === "executing";
// Get result summary for completed tools
const resultSummary =
isComplete && config.getResultSummary
? config.getResultSummary(result, args)
: null;
// Determine if this tool has expandable content
const hasExpandableContent =
isComplete && (name === "research" || name === "write_todos");
return (
<div
className={`
glass-subtle
transition-all duration-200
${isComplete ? "opacity-80" : ""}
${hasExpandableContent ? "cursor-pointer" : ""}
`}
style={{
padding: "var(--space-4)",
marginBottom: "var(--space-2)",
}}
onClick={hasExpandableContent ? () => setExpanded(!expanded) : undefined}
>
<div className="flex items-center" style={{ gap: "var(--space-3)" }}>
<div
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{
background: isComplete
? "rgba(21, 128, 61, 0.1)"
: "rgba(217, 119, 6, 0.1)",
}}
>
{isComplete ? (
<Check
size={16}
strokeWidth={2}
style={{ color: "var(--color-success)" }}
/>
) : (
<config.icon
size={16}
strokeWidth={2}
className={isExecuting ? "animate-spin-slow" : ""}
style={{ color: "var(--color-accent)" }}
/>
)}
</div>
<div className="flex-1 min-w-0">
<p
className={`
text-sm font-medium
${
isComplete
? "text-[var(--color-text-tertiary)]"
: "text-[var(--color-text-primary)]"
}
`}
>
{config.getDisplayText(args)}
</p>
{/* Result summary shown below the display text when complete */}
{resultSummary && (
<p
className="text-xs mt-0.5"
style={{ color: "var(--color-success)" }}
>
{resultSummary}
</p>
)}
</div>
{/* Expand indicator for expandable tools */}
{hasExpandableContent && (
<ChevronDown
className={`w-4 h-4 text-[var(--color-text-tertiary)] transition-transform ${expanded ? "rotate-180" : ""}`}
/>
)}
</div>
{/* Expanded details section */}
{expanded && isComplete && (
<div
style={{ marginTop: "var(--space-3)", paddingTop: "var(--space-3)" }}
className="border-t border-[var(--color-border-glass)]"
>
<ExpandedDetails name={name} result={result} args={args} />
</div>
)}
</div>
);
}
/**
* Renders expanded details based on tool type.
* Each tool has its own structured view of the result.
*/
function ExpandedDetails({
name,
result,
args,
}: {
name: string;
result: unknown;
args: Record<string, unknown>;
}) {
// research: show the full prose summary
if (name === "research") {
// Extract summary from object or use string directly
const summary =
typeof result === "object" && result && "summary" in result
? (result as { summary: string; sources: unknown[] }).summary
: typeof result === "string"
? result
: "";
if (!summary)
return (
<p className="text-xs text-[var(--color-text-tertiary)]">No findings</p>
);
return (
<div className="space-y-2">
<p className="text-xs font-medium text-[var(--color-text-tertiary)]">
Query:
</p>
<p className="text-xs text-[var(--color-text-secondary)]">
{(args.query as string) || "..."}
</p>
<p className="text-xs font-medium text-[var(--color-text-tertiary)] mt-2">
Findings:
</p>
<p className="text-sm text-[var(--color-text-primary)] whitespace-pre-wrap">
{summary}
</p>
</div>
);
}
// write_todos: show todo list (from args, not result)
if (name === "write_todos") {
const todos = (
args as { todos?: Array<{ id: string; content: string; status: string }> }
)?.todos;
if (!todos?.length)
return (
<p className="text-xs text-[var(--color-text-tertiary)]">No todos</p>
);
return (
<div className="space-y-1 max-h-40 overflow-y-auto">
{todos.map((todo, i) => (
<div key={todo.id || i} className="flex items-start gap-2 text-xs">
<span
className="mt-0.5"
style={{
color:
todo.status === "completed"
? "var(--color-success)"
: todo.status === "in_progress"
? "var(--color-accent-dark)"
: "var(--color-text-tertiary)",
}}
>
{todo.status === "completed"
? "✓"
: todo.status === "in_progress"
? "●"
: "○"}
</span>
<span
className={
todo.status === "completed"
? "line-through text-[var(--color-text-tertiary)]"
: ""
}
>
{todo.content}
</span>
</div>
))}
</div>
);
}
// Fallback: JSON display
return (
<pre className="text-xs bg-[var(--color-container)] p-2 rounded-md overflow-auto max-h-32 border border-[var(--color-border)]">
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
</pre>
);
}
function DefaultToolCard({ name, status, args, result }: ToolCardProps) {
const [expanded, setExpanded] = useState(false);
const isComplete = status === "complete";
return (
<div className="glass-subtle p-3 my-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className={`
w-8 h-8 rounded-lg flex items-center justify-center
text-lg
${
isComplete
? "bg-[var(--color-mint)]/20"
: "bg-[var(--color-lilac)]/20"
}
`}
>
{isComplete ? "✓" : "⚙️"}
</div>
<div className="flex items-center gap-2">
<code className="text-sm text-[var(--color-text-primary)]">
{name}
</code>
<span
className={`
text-xs px-2 py-0.5 rounded-full
${
isComplete
? "bg-[var(--color-mint)]/20 text-[var(--color-mint-dark)]"
: "bg-[var(--color-lilac)]/20 text-[var(--color-lilac-dark)]"
}
`}
>
{status}
</span>
</div>
</div>
<button
onClick={() => setExpanded(!expanded)}
className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
>
<ChevronDown
className={`w-4 h-4 transition-transform ${expanded ? "rotate-180" : ""}`}
/>
</button>
</div>
{expanded && (
<div className="mt-3 space-y-2">
<div>
<p className="text-xs text-[var(--color-text-tertiary)] mb-1">
Arguments:
</p>
<pre className="text-xs bg-[var(--color-container)] p-2 rounded-md overflow-auto max-h-32 border border-[var(--color-border)]">
{JSON.stringify(args, null, 2)}
</pre>
</div>
{result !== undefined && result !== null && (
<div>
<p className="text-xs text-[var(--color-text-tertiary)] mb-1">
Result:
</p>
<pre className="text-xs bg-[var(--color-container)] p-2 rounded-md overflow-auto max-h-32 border border-[var(--color-border)]">
{typeof result === "string"
? result
: JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,350 @@
"use client";
import { useState } from "react";
import {
ChevronDown,
ChevronRight,
ListTodo,
FileText,
Download,
Globe,
Check,
Circle,
CircleDot,
X,
} from "lucide-react";
import { ResearchState, Todo, ResearchFile, Source } from "@/types/research";
import { FileViewerModal } from "@/components/FileViewerModal";
// Helper function to download file content
function downloadFile(file: ResearchFile) {
const blob = new Blob([file.content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.path.split("/").pop() || "file.txt";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
interface WorkspaceProps {
state: ResearchState;
}
// Collapsible section component with smooth transitions
function Section({
title,
icon: Icon,
children,
defaultOpen = true,
badge,
}: {
title: string;
icon: React.ElementType;
children: React.ReactNode;
defaultOpen?: boolean;
badge?: number;
}) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="workspace-section">
<button
onClick={() => setIsOpen(!isOpen)}
className="workspace-section-header w-full transition-all duration-200"
>
<div className="flex items-center gap-3">
<Icon className="w-5 h-5 text-[var(--color-text-secondary)]" />
<span className="font-semibold text-[var(--color-text-primary)]">
{title}
</span>
{badge !== undefined && badge > 0 && (
<span
style={{
background: "var(--color-accent)",
color: "var(--color-background)",
padding: "var(--space-1) var(--space-2)",
fontSize: "var(--text-xs)",
fontWeight: "var(--font-semibold)",
borderRadius: "var(--radius-lg)",
}}
>
{badge}
</span>
)}
</div>
{isOpen ? (
<ChevronDown className="w-5 h-5 text-[var(--color-text-tertiary)] transition-transform" />
) : (
<ChevronRight className="w-5 h-5 text-[var(--color-text-tertiary)] transition-transform" />
)}
</button>
{isOpen && <div className="workspace-section-content">{children}</div>}
</div>
);
}
// Todo list component with animations
function TodoList({ todos }: { todos: Todo[] }) {
if (todos.length === 0) {
return (
<div
className="empty-state"
style={{
paddingTop: "var(--space-8)",
paddingBottom: "var(--space-8)",
animation: "fadeIn 0.4s ease",
}}
>
<ListTodo
size={32}
strokeWidth={1.5}
style={{
color: "var(--color-text-tertiary)",
marginBottom: "var(--space-3)",
}}
/>
<p style={{ fontSize: "var(--text-sm)" }}>No tasks yet</p>
<p className="text-xs mt-1">Research tasks will appear here</p>
</div>
);
}
return (
<div className="space-y-1">
{todos.map((todo) => (
<div
key={todo.id}
className={`todo-item animate-fadeSlideIn ${
todo.status === "completed"
? "todo-item-completed"
: todo.status === "in_progress"
? "todo-item-inprogress"
: "todo-item-pending"
}`}
>
<span
className={`${
todo.status === "completed"
? "status-completed"
: todo.status === "in_progress"
? "status-inprogress"
: "status-pending"
}`}
>
{todo.status === "completed" ? (
<Check size={14} />
) : todo.status === "in_progress" ? (
<CircleDot size={14} />
) : (
<Circle size={14} />
)}
</span>
<span className="text-sm">{todo.content}</span>
</div>
))}
</div>
);
}
// File list component with click-to-view and animations
function FileList({
files,
onFileClick,
}: {
files: ResearchFile[];
onFileClick: (file: ResearchFile) => void;
}) {
if (files.length === 0) {
return (
<div
className="empty-state"
style={{
paddingTop: "var(--space-8)",
paddingBottom: "var(--space-8)",
animation: "fadeIn 0.4s ease",
}}
>
<FileText
size={32}
strokeWidth={1.5}
style={{
color: "var(--color-text-tertiary)",
marginBottom: "var(--space-3)",
}}
/>
<p style={{ fontSize: "var(--text-sm)" }}>No files yet</p>
<p className="text-xs mt-1">Research artifacts will appear here</p>
</div>
);
}
return (
<div className="space-y-2">
{files.map((file, i) => (
<div
key={`${file.path}-${i}`}
className="file-item animate-fadeSlideIn"
onClick={() => onFileClick(file)}
>
<div className="flex items-center gap-3">
<div className="file-item-icon">
<FileText className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-medium text-[var(--color-text-primary)]">
{file.path.split("/").pop()}
</p>
<p className="text-xs text-[var(--color-text-tertiary)]">
{file.path}
</p>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation(); // Don't trigger file view on download click
downloadFile(file);
}}
className="p-2 rounded-lg hover:bg-[var(--color-glass-subtle)] transition-colors"
aria-label="Download file"
title="Download file"
>
<Download className="w-4 h-4 text-[var(--color-text-secondary)]" />
</button>
</div>
))}
</div>
);
}
// Source list component with error states and animations
function SourceList({ sources }: { sources: Source[] }) {
if (sources.length === 0) {
return (
<div
className="empty-state"
style={{
paddingTop: "var(--space-8)",
paddingBottom: "var(--space-8)",
animation: "fadeIn 0.4s ease",
}}
>
<Globe
size={32}
strokeWidth={1.5}
style={{
color: "var(--color-text-tertiary)",
marginBottom: "var(--space-3)",
}}
/>
<p style={{ fontSize: "var(--text-sm)" }}>No sources yet</p>
<p className="text-xs mt-1">Web sources will appear here</p>
</div>
);
}
return (
<div className="space-y-2">
{sources.map((source, i) => (
<div
key={`${source.url}-${i}`}
className={`file-item animate-fadeSlideIn ${source.status === "failed" ? "source-failed" : ""}`}
title={
source.status === "failed"
? "Failed to scrape this source"
: undefined
}
>
<div className="flex items-center gap-3">
<span
className={`source-indicator ${
source.status === "scraped"
? "status-completed"
: source.status === "failed"
? ""
: "status-pending"
}`}
style={
source.status === "failed"
? { color: "var(--color-error)" }
: undefined
}
>
{source.status === "scraped" ? (
<Check size={14} style={{ color: "var(--color-success)" }} />
) : source.status === "failed" ? (
<X size={14} style={{ color: "var(--color-error)" }} />
) : (
<Circle size={14} />
)}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--color-text-primary)] truncate">
{source.title ||
(() => {
try {
return new URL(source.url).hostname;
} catch {
return source.url.slice(0, 40);
}
})()}
</p>
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-accent)] truncate block"
>
{source.url}
</a>
</div>
</div>
</div>
))}
</div>
);
}
// Main Workspace component
export function Workspace({ state }: WorkspaceProps) {
const { todos, files, sources } = state;
const fileCount = files.length;
const todoCount = todos.length;
const sourceCount = sources.length;
// State for file viewer modal
const [selectedFile, setSelectedFile] = useState<ResearchFile | null>(null);
return (
<div className="workspace-panel p-6">
<div className="mb-6">
<h2 className="text-xl font-bold text-[var(--color-text-primary)]">
Workspace
</h2>
<p className="text-sm text-[var(--color-text-secondary)]">
Research progress and artifacts
</p>
</div>
<Section title="Research Plan" icon={ListTodo} badge={todoCount}>
<TodoList todos={todos} />
</Section>
<Section title="Files" icon={FileText} badge={fileCount}>
<FileList files={files} onFileClick={setSelectedFile} />
</Section>
<Section title="Sources" icon={Globe} badge={sourceCount}>
<SourceList sources={sources} />
</Section>
{/* File Viewer Modal */}
<FileViewerModal
file={selectedFile}
onClose={() => setSelectedFile(null)}
/>
</div>
);
}
@@ -0,0 +1,39 @@
/**
* Research State Types
*
* Types for managing research state in the Deep Research Assistant.
* Uses local state + useDefaultTool pattern instead of useCoAgent
* to avoid type mismatches with Python FilesystemMiddleware.
*/
export interface Todo {
id: string;
content: string;
status: "pending" | "in_progress" | "completed";
}
export interface ResearchFile {
path: string;
content: string;
createdAt: string;
}
// Sources found via internet_search (includes content)
export interface Source {
url: string;
title: string;
content?: string;
status: "found" | "scraped" | "failed";
}
export interface ResearchState {
todos: Todo[];
files: ResearchFile[];
sources: Source[];
}
export const INITIAL_STATE: ResearchState = {
todos: [],
files: [],
sources: [],
};
@@ -0,0 +1,34 @@
{
"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": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}