mirror of
https://github.com/Shubhamsaboo/awesome-llm-apps.git
synced 2026-08-28 11:07:19 +08:00
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:
@@ -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
|
||||
+1521
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;
|
||||
+16386
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"]
|
||||
}
|
||||
Reference in New Issue
Block a user