端到端教程展示用 LangGraph、CopilotKit、Tavily 搭建 AI 驱动搜索应用。完整涵盖 Agent 设计、后端构建和前端集成的全流程。
CopilotKit 现已支持 Angular
Angular 支持 · 使用 Angular 构建智能体应用与生成式 UI · 现已推出
在这篇全面的教程中,我们将逐步介绍如何使用 LangGraph、Tavily 和 CopilotKit 构建一个类似 Perplexity 的应用。该应用的核心是使用 CoAgents 处理用户请求,执行多次搜索,并将状态更新和结果实时流式传输到前端。这种方式让用户掌握主动权,使他们能够在搜索过程中引导并优化搜索。
要充分理解本教程,你需要具备 React 或 Next.js 的基础知识。
我们还将使用以下工具:
CopilotKit:用于构建生产就绪型 AI 智能体和 Copilot 的框架。
LangGraph:用于创建和部署 AI 智能体的框架。它还可以帮助定义 AI 智能体要执行的控制流程和操作。
Tavily AI:一个搜索引擎,使 AI 智能体能够在应用内开展研究并获取实时知识。
Shad Cn UI:为应用提供一组可复用的 UI 组件。
在本节中,你将学习如何使用 LangGraph 和 CopilotKit 创建 AI 智能体。
首先,克隆 CopilotKit CoAgents Starter 仓库。ui 目录包含 Next.js 应用的前端,agent 目录则包含该应用的 CoAgent。
进入 agent 目录,使用 Poetry 安装项目依赖:
cd agent
poetry install
在 agent 文件夹中创建一个 .env 文件,并将你的 OpenAI 和 Tavily AI API 密钥复制到该文件中:
OPENAI_API_KEY=<YOUR KEY>
TAVILY_API_KEY=<YOUR KEY>
将下面的代码片段复制到 agent.py 文件中:
"""
This is the main entry point for the AI.
It defines the workflow graph and the entry point for the agent.
"""
# pylint: disable=line-too-long, unused-import
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.memory import MemorySaver
from ai_researcher.state import AgentState
from ai_researcher.steps import steps_node
from ai_researcher.search import search_node
from ai_researcher.summarize import summarize_node
from ai_researcher.extract import extract_node
def route(state):
"""Route to research nodes."""
if not state.get("steps", None):
return END
current_step = next((step for step in state["steps"] if step["status"] == "pending"), None)
if not current_step:
return "summarize_node"
if current_step["type"] == "search":
return "search_node"
raise ValueError(f"Unknown step type: {current_step['type']}")
# Define a new graph
workflow = StateGraph(AgentState)
workflow.add_node("steps_node", steps_node)
workflow.add_node("search_node", search_node)
workflow.add_node("summarize_node", summarize_node)
workflow.add_node("extract_node", extract_node)
# Chatbot
workflow.set_entry_point("steps_node")
workflow.add_conditional_edges(
"steps_node",
route,
["summarize_node", "search_node", END]
)
workflow.add_edge("search_node", "extract_node")
workflow.add_conditional_edges(
"extract_node",
route,
["summarize_node", "search_node"]
)
workflow.add_edge("summarize_node", END)
memory = MemorySaver()
graph = workflow.compile(checkpointer=memory)
上面的代码片段定义了 LangGraph AI 智能体的工作流。LangGraph Studio 可以帮助我们可视化整个流程:先运行 steps_node,然后搜索结果、对结果进行总结,并提取关键要点。
使用下面的代码片段更新 demo.py 文件:
"""Demo"""
import os
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI
import uvicorn
from copilotkit.integrations.fastapi import add_fastapi_endpoint
from copilotkit import CopilotKitSDK, LangGraphAgent
from ai_researcher.agent import graph
app = FastAPI()
sdk = CopilotKitSDK(
agents=[
LangGraphAgent(
name="ai_researcher",
description="Search agent.",
graph=graph,
)
],
)
add_fastapi_endpoint(app, sdk, "/copilotkit")
# add new route for health check
@app.get("/health")
def health():
"""Health check."""
return {"status": "ok"}
def main():
"""Run the uvicorn server."""
port = int(os.getenv("PORT", "8000"))
uvicorn.run("ai_researcher.demo:app", host="0.0.0.0", port=port, reload=True)
上面的代码片段创建了一个 FastAPI 端点,用于托管 LangGraph AI 智能体,并将其连接到 CopilotKit SDK。
你可以从 GitHub 仓库复制创建 CoAgent 所需的其余代码。在接下来的章节中,你将学习如何构建 Perplexity 克隆应用的用户界面,以及如何使用 CopilotKit 处理搜索请求。
在本节中,我将带你完成应用用户界面的构建过程。
首先,运行下面的代码片段创建一个 Next.js TypeScript 项目:
# 👉🏻 Navigate into the ui folder
npx create-next-app ./
运行下面的代码片段,在新创建的项目中安装 ShadCn UI 库:
npx shadcn@latest init
接下来,在 Next.js 项目的根目录中创建一个 components 文件夹,然后将此 GitHub 仓库中的 ui 文件夹复制到该文件夹中。Shadcn 允许你通过命令行安装各种组件,从而轻松地将它们添加到应用中。
除了 Shadcn 组件之外,你还需要创建几个用于表示应用界面不同部分的组件。在 components 文件夹中运行下面的代码片段,将这些组件添加到 Next.js 项目:
touch ResearchWrapper.tsx ResultsView.tsx HomeView.tsx
touch AnswerMarkdown.tsx Progress.tsx SkeletonLoader.tsx
将下面的代码片段复制到 app/page.tsx 文件中:
"use client";
import { ResearchWrapper } from "@/components/ResearchWrapper";
import { ModelSelectorProvider, useModelSelectorContext } from "@/lib/model-selector-provider";
import { ResearchProvider } from "@/lib/research-provider";
import { CopilotKit } from "@copilotkit/react-core";
import "@copilotkit/react-ui/styles.css";
export default function ModelSelectorWrapper() {
return (
<CopilotKit runtimeUrl={useLgc ? "/api/copilotkit-lgc" : "/api/copilotkit"} agent="ai_researcher">
<ResearchProvider>
<ResearchWrapper />
</ResearchProvider>
</CopilotKit>
);
}
在上面的代码片段中,ResearchProvider 是一个自定义 React Context Provider,用于共享用户的搜索查询和搜索结果,使应用中的所有组件都能访问这些数据。ResearchWrapper 组件包含应用的核心元素,并负责管理 UI。
在 Next.js 项目的根目录中创建一个 lib 文件夹,并在其中创建 research-provider.tsx 文件,然后将以下代码复制到该文件中:
import { createContext, useContext, useState, ReactNode, useEffect } from "react";
type ResearchContextType = {
researchQuery: string;
setResearchQuery: (query: string) => void;
researchInput: string;
setResearchInput: (input: string) => void;
isLoading: boolean;
setIsLoading: (loading: boolean) => void;
researchResult: ResearchResult | null;
setResearchResult: (result: ResearchResult) => void;
};
type ResearchResult = {
answer: string;
sources: string[];
}
const ResearchContext = createContext<ResearchContextType | undefined>(undefined);
export const ResearchProvider = ({ children }: { children: ReactNode }) => {
const [researchQuery, setResearchQuery] = useState<string>("");
const [researchInput, setResearchInput] = useState<string>("");
const [researchResult, setResearchResult] = useState<ResearchResult | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
useEffect(() => {
if (!researchQuery) {
setResearchResult(null);
setResearchInput("");
}
}, [researchQuery, researchResult]);
return (
<ResearchContext.Provider
value={{
researchQuery,
setResearchQuery,
researchInput,
setResearchInput,
isLoading,
setIsLoading,
researchResult,
setResearchResult,
}}
>
{children}
</ResearchContext.Provider>
);
};
export const useResearchContext = () => {
const context = useContext(ResearchContext);
if (context === undefined) {
throw new Error("useResearchContext must be used within a ResearchProvider");
}
return context;
};
这些状态会被声明并保存到 ResearchContext 中,以确保它们能在应用的多个组件之间得到妥善管理。
按照下面的代码更新 ResearchWrapper 组件:
import { HomeView } from "./HomeView";
import { ResultsView } from "./ResultsView";
import { AnimatePresence } from "framer-motion";
import { useResearchContext } from "@/lib/research-provider";
export function ResearchWrapper() {
const { researchQuery, setResearchInput } = useResearchContext();
return (
<>
<div className="flex flex-col items-center justify-center relative z-10">
<div className="flex-1">
{researchQuery ? (
<AnimatePresence
key="results"
onExitComplete={() => {
setResearchInput("");
}}
mode="wait"
>
<ResultsView key="results" />
</AnimatePresence>
) : (
<AnimatePresence key="home" mode="wait">
<HomeView key="home" />
</AnimatePresence>
)}
</div>
<footer className="text-xs p-2">
<a
href="https://copilotkit.ai"
target="_blank"
rel="noopener noreferrer"
className="text-slate-600 font-medium hover:underline"
>
Powered by CopilotKit 🪁
</a>
</footer>
</div>
</>
);
}
ResearchWrapper 组件将 HomeView 组件渲染为默认视图,当提供搜索查询时显示 ResultsView。useResearchContext hook 使我们能够访问 researchQuery 状态并相应地更新视图。
最后,更新 HomeView 组件以呈现应用程序首页界面。
"use client";
import { useEffect, useState } from "react";
import { Textarea } from "./ui/textarea";
import { cn } from "@/lib/utils";
import { Button } from "./ui/button";
import { CornerDownLeftIcon } from "lucide-react";
import { useResearchContext } from "@/lib/research-provider";
import { motion } from "framer-motion";
import { useCoAgent } from "@copilotkit/react-core";
import { TextMessage, MessageRole } from "@copilotkit/runtime-client-gql";
import type { AgentState } from "../lib/types";
import { useModelSelectorContext } from "@/lib/model-selector-provider";
const MAX_INPUT_LENGTH = 250;
export function HomeView() {
const { setResearchQuery, researchInput, setResearchInput } =
useResearchContext();
const { model } = useModelSelectorContext();
const [isInputFocused, setIsInputFocused] = useState(false);
const {
run: runResearchAgent,
} = useCoAgent<AgentState>({
name: "ai_researcher",
initialState: {
model,
},
});
const handleResearch = (query: string) => {
setResearchQuery(query);
runResearchAgent(() => {
return new TextMessage({
role: MessageRole.User,
content: query,
});
});
};
const suggestions = [
{ label: "Electric cars sold in 2024 vs 2023", icon: "🚙" },
{ label: "Top 10 richest people in the world", icon: "💰" },
{ label: "Population of the World", icon: "🌍 " },
{ label: "Weather in Seattle VS New York", icon: "⛅️" },
];
return (
<motion.div
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.4 }}
className="h-screen w-full flex flex-col gap-y-2 justify-center items-center p-4 lg:p-0"
>
<h1 className="text-4xl font-extralight mb-6">
What would you like to know?
</h1>
<div
className={cn(
"w-full bg-slate-100/50 border shadow-sm rounded-md transition-all",
{
"ring-1 ring-slate-300": isInputFocused,
}
)}
>
<Textarea
placeholder="Ask anything..."
className="bg-transparent p-4 resize-none focus-visible:ring-0 focus-visible:ring-offset-0 border-0 w-full"
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
value={researchInput}
onChange={(e) => setResearchInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleResearch(researchInput);
}
}}
maxLength={MAX_INPUT_LENGTH}
/>
<div className="text-xs p-4 flex items-center justify-between">
<div
className={cn("transition-all duration-300 mt-4 text-slate-500", {
"opacity-0": !researchInput,
"opacity-100": researchInput,
})}
>
{researchInput.length} / {MAX_INPUT_LENGTH}
</div>
<Button
size="sm"
className={cn("rounded-full transition-all duration-300", {
"opacity-0 pointer-events-none": !researchInput,
"opacity-100": researchInput,
})}
onClick={() => handleResearch(researchInput)}
>
Research
<CornerDownLeftIcon className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
<div className="grid grid-cols-2 w-full gap-2 text-sm">
{suggestions.map((suggestion) => (
<div
key={suggestion.label}
onClick={() => handleResearch(suggestion.label)}
className="p-2 bg-slate-100/50 rounded-md border col-span-2 lg:col-span-1 flex cursor-pointer items-center space-x-2 hover:bg-slate-100 transition-all duration-300"
>
<span className="text-base">{suggestion.icon}</span>
<span className="flex-1">{suggestion.label}</span>
</div>
))}
</div>
</motion.div>
);
}
在本节中,您将学习如何将 CopilotKit CoAgent 连接到 Next.js 应用程序,以使用户能够在应用程序内执行搜索操作。
安装以下 CopilotKit 包和 OpenAI Node.js SDK。CopilotKit 包允许 co-agent 与 React 状态值交互并在应用程序内做出决策。
npm install @copilotkit/react-core @copilotkit/react-ui @copilotkit/runtime @copilotkit/runtime-client-gql openai
在 Next.js app 文件夹内创建一个 api 文件夹。在 api 文件夹内,创建一个包含 route.ts 文件的 copilotkit 目录。这将创建一个 API 端点 (/api/copilotkit),该端点将前端应用程序连接到 CopilotKit CoAgent。
cd app
mkdir api && cd api
mkdir copilotkit && cd copilotkit
touch route.ts
将下面的代码片段复制到 api/copilotkit/route.ts 文件中:
mport { NextRequest } from "next/server";
import {
CopilotRuntime,
OpenAIAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import OpenAI from "openai";
//👇🏻 initializes OpenAI as the adapter
const openai = new OpenAI();
const serviceAdapter = new OpenAIAdapter({ openai } as any);
//👇🏻 connects the CopilotKit runtime to the CoAgent
const runtime = new CopilotRuntime({
remoteEndpoints: [
{
url: process.env.REMOTE_ACTION_URL || "http://localhost:8000/copilotkit",
},
],
});
export const POST = async (req: NextRequest) => {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter,
endpoint: "/api/copilotkit",
});
return handleRequest(req);
};
上面的代码片段在 /api/copilotkit API 端点设置 CopilotKit 运行时,允许 CopilotKit 通过 AI co-agent 处理用户请求。
最后,通过用 CopilotKit 组件包装整个应用程序来更新 app/page.tsx,该组件向所有应用程序组件提供 copilot 上下文。
"use client";
import { ModelSelector } from "@/components/ModelSelector";
import { ResearchWrapper } from "@/components/ResearchWrapper";
import { ModelSelectorProvider, useModelSelectorContext } from "@/lib/model-selector-provider";
import { ResearchProvider } from "@/lib/research-provider";
import { CopilotKit } from "@copilotkit/react-core";
import "@copilotkit/react-ui/styles.css";
export default function ModelSelectorWrapper() {
return (
<main className="flex flex-col items-center justify-between">
<ModelSelectorProvider>
<Home />
<ModelSelector />
</ModelSelectorProvider>
</main>
);
}
function Home() {
const { useLgc } = useModelSelectorContext();
return (
<CopilotKit runtimeUrl={useLgc ? "/api/copilotkit-lgc" : "/api/copilotkit"} agent="ai_researcher">
<ResearchProvider>
<ResearchWrapper />
</ResearchProvider>
</CopilotKit>
);
}
CopilotKit 组件包装整个应用程序并接受两个 props - runtimeUrl 和 agent。runtimeUrl 是托管 AI 智能体的后端 API 路由,agent 是执行操作的智能体的名称。
为了使 CopilotKit 能够访问和处理用户输入,它提供了 useCoAgent hook,允许从应用程序内的任何地方访问智能体的状态。
例如,下面的代码片段演示了如何使用 useCoAgent hook。state 变量允许访问智能体的当前状态,setState 用于修改状态,run 函数使用智能体执行指令。start 和 stop 函数分别启动和停止智能体的执行。
const { state, setState, run, start, stop } = useCoAgent({
name: "search_agent",
});
更新 HomeView 组件,使其在提供搜索查询时执行智能体。
//👇🏻 从 CopilotKit 导入 useCoAgent hook
import { useCoAgent } from "@copilotkit/react-core";
const { run: runResearchAgent } = useCoAgent({
name: "search_agent",
});
const handleResearch = (query: string) => {
setResearchQuery(query);
runResearchAgent(query); //👉🏻 启动智能体执行
};
接下来,你可以通过访问 useCoAgent hook 中的 state 变量,将搜索结果流式传输到 ResultsView。将下面的代码片段复制到 ResultsView 组件中。
"use client";
import { useResearchContext } from "@/lib/research-provider";
import { motion } from "framer-motion";
import { BookOpenIcon, LoaderCircleIcon, SparkleIcon } from "lucide-react";
import { SkeletonLoader } from "./SkeletonLoader";
import { useCoAgent } from "@copilotkit/react-core";
import { Progress } from "./Progress";
import { AnswerMarkdown } from "./AnswerMarkdown";
export function ResultsView() {
const { researchQuery } = useResearchContext();
//👇🏻 智能体状态
const { state: agentState } = useCoAgent({
name: "search_agent",
});
console.log("AGENT_STATE", agentState);
//👇🏻 跟踪智能体的当前处理状态
const steps =
agentState?.steps?.map((step: any) => {
return {
description: step.description || "",
status: step.status || "pending",
updates: step.updates || [],
};
}) || [];
const isLoading = !agentState?.answer?.markdown;
return (
<motion.div
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -50 }}
transition={{ duration: 0.5, ease: "easeOut" }}
>
<div className='max-w-[1000px] p-8 lg:p-4 flex flex-col gap-y-8 mt-4 lg:mt-6 text-sm lg:text-base'>
<div className='space-y-4'>
<h1 className='text-3xl lg:text-4xl font-extralight'>
{researchQuery}
</h1>
</div>
<Progress steps={steps} />
<div className='grid grid-cols-12 gap-8'>
<div className='col-span-12 lg:col-span-8 flex flex-col'>
<h2 className='flex items-center gap-x-2'>
{isLoading ? (
<LoaderCircleIcon className='animate-spin w-4 h-4 text-slate-500' />
) : (
<SparkleIcon className='w-4 h-4 text-slate-500' />
)}
Answer
</h2>
<div className='text-slate-500 font-light'>
{isLoading ? (
<SkeletonLoader />
) : (
<AnswerMarkdown markdown={agentState?.answer?.markdown} /> //👈🏼 显示搜索结果
)}
</div>
</div>
{agentState?.answer?.references?.length && (
<div className='flex col-span-12 lg:col-span-4 flex-col gap-y-4 w-[200px]'>
<h2 className='flex items-center gap-x-2'>
<BookOpenIcon className='w-4 h-4 text-slate-500' />
References
</h2>
<ul className='text-slate-900 font-light text-sm flex flex-col gap-y-2'>
{agentState?.answer?.references?.map(
(ref: any, idx: number) => (
<li key={idx}>
<a
href={ref.url}
target='_blank'
rel='noopener noreferrer'
>
{idx + 1}. {ref.title}
</a>
</li>
)
)}
</ul>
</div>
)}
</div>
</div>
</motion.div>
);
}
上面的代码片段从智能体的状态中检索搜索结果,并使用 useCoAgent hook 将其流式传输到前端。搜索结果以 markdown 格式返回,并传递给 AnswerMarkdown 组件,该组件在页面上渲染内容。
最后,将下面的代码片段复制到 AnswerMarkdown 组件中。这将使用 React Markdown 库将 markdown 内容作为格式化文本呈现。
import Markdown from "react-markdown";
export function AnswerMarkdown({ markdown }: { markdown: string }) {
return (
<div className='markdown-wrapper'>
<Markdown>{markdown}</Markdown>
</div>
);
}
恭喜!你已完成本教程的项目。要了解该项目的详细概览,你可以观看我们最近的网络研讨会,在那里我们展示了这些功能的实际应用。
完整网络研讨会录制
LLM 智能最有效的方式是与人类智能相结合,CopilotKit CoAgents 允许你在几分钟内将 AI 智能体、Copilot 和各种类型的助手集成到你的软件应用中。
如果你需要构建 AI 产品或将 AI 智能体集成到你的软件应用中,你应该考虑使用 CopilotKit。
本教程的源代码可在 GitHub 上获取:
感谢阅读