EcoGPT:从 0 到 1 打造专业环保知识问答 AI 助手

287 阅读10分钟

项目概述:为什么要做 EcoGPT?

在 “双碳” 目标和环保意识提升的背景下,普通人想践行可持续生活却常遇难题:不知道如何正确分类垃圾、找不到本地回收点、不清楚哪些品牌真正环保…… 但大模型(如 GPT)的知识存在 “滞后性” 和 “泛化性”,无法精准回答实时环保政策、本地化信息或专业领域细节。

于是,EcoGPT—— 专注环保领域的 AI 问答助手应运而生。它基于「检索增强生成(RAG)」技术,将实时环保知识库与大模型结合,既能像普通 ChatBot 一样对话,又能提供精准、专业的环保建议。

技术栈选择

做项目的第一步不是写代码,而是选对工具 —— 合适的技术栈能让开发效率提升 50%。EcoGPT 的技术栈围绕 “快速开发 + 专业能力 + 流畅体验” 设计,具体清单和选型理由如下:

技术领域核心工具选型理由
前端框架Next.js 14支持 SSR/CSR 混合模式(SEO 友好 + 交互流畅),内置路由,适配客户端组件(use client)
样式方案Tailwind CSS移动优先设计,无需写自定义 CSS,通过类名快速实现响应式布局(如适配手机 / PC)
AI 交互核心@ai-sdk/core + @ai-sdk/react封装 LLM 调用逻辑,1 行 hooks 搞定 “输入管理 + 流式输出 + 消息存储”,无需重复造轮子
大模型OpenAI GPT-4o-mini + text-embedding-3-small轻量高效(GPT-4o-mini 成本低、响应快),embedding 模型生成 1536 维向量,适配向量数据库
向量数据库Supabase(PostgreSQL)BAAS 服务(无需自建后端),PostgreSQL 支持向量类型,内置相似度计算,还能写 RPC 函数
知识库构建LangChain + PuppeteerLangChain 提供文本分块 / 加载工具,Puppeteer(无头浏览器)爬取实时环保网站内容
辅助工具lucide-react + react-markdown轻量图标库(Leaf/User/Bot 图标),Markdown 渲染(AI 输出支持格式化)
配置管理dotenv + TypeScript环境变量安全管理,TS 类型约束减少 bug(如组件 Props 定义)

全流程实现:从代码到运行的每一步

第一步:项目初始化与环境配置

1. 创建 Next.js 项目

npx create-next-app@latest ecogpt --typescript --tailwind --app
cd ecogpt

2. 安装依赖

npm install ai @ai-sdk/openai @ai-sdk/react  @supabase/supabase-js
npm install @langchain/community puppeteer lucide-react react-markdown dotenv
npm install -D ts-node # 用于运行TS脚本(如爬虫)

3. 配置环境变量(.env.local)

创建.env.local文件,填入关键信息(需从 Supabase、OpenAI 控制台获取):

# Supabase配置
NEXT_PUBLIC_SUPABASE_URL=你的Supabase项目URL
NEXT_PUBLIC_SUPABASE_KEY=你的Supabase匿名密钥
# OpenAI配置
OPENAI_API_KEY=你的OpenAI API密钥
OPENAI_API_BASE_URL=你的OpenAI BaseURL(如用代理可修改)

4. 解决 TS-Node 不支持 ESM 问题

修改tsconfig.json,确保ts-node能运行爬虫脚本:

{
  "compilerOptions": {
    "module": "CommonJS", // 关键:改为CommonJS,支持ts-node
    "moduleResolution": "Node",
    "target": "ES2018",
    "jsx": "preserve",
    "strict": true,
    "esModuleInterop": true
  }
}

第二步:RAG 核心 —— 构建环保知识库(爬虫 + 向量存储)

RAG 是 EcoGPT “专业能力” 的核心,本质是 “先检索专业知识,再让 AI 基于知识回答”。这一步要完成两个关键动作:爬取环保数据 → 存入向量数据库

1. 第一步:创建 Supabase 表与 RPC 函数

首先在 Supabase 控制台做两件事:

  • 创建 chunks 表:存储分块后的文本、向量、来源 URL

    CREATE TABLE chunks (
      id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
      content TEXT NOT NULL, -- 分块后的文本内容
      vector VECTOR(1536) NOT NULL, -- 1536维向量(匹配text-embedding-3-small)
      url TEXT NOT NULL, -- 内容来源URL
      date_updated TIMESTAMP DEFAULT NOW() -- 更新时间
    );
    
  • 创建 RPC 函数 get_relevant_chunks:用于根据用户问题的向量,检索相似度最高的内容

    create or replace function get_relevant_chunks(
      query_vector vector(1536), -- 用户问题的向量
      match_threshold float, -- 相似度阈值(0.7以上视为相关)
      match_count int -- 最多返回5条结果
    )
    returns table (
      id uuid,
      content text,
      url text,
      date_updated timestamp,
      similarity float
    )
    language sql stable
    as $$
      select
        id,
        content,
        url,
        date_updated,
        1 - (chunks.vector <=> query_vector) as similarity -- cos相似度计算(1-距离=相似度)
      from chunks
      where 1 - (chunks.vector <=> query_vector) > match_threshold
      order by similarity desc -- 按相似度倒序
      limit match_count;
    $$;
    

2. 第二步:写爬虫脚本(爬取环保网站 + 分块 + 向量存储)

创建scripts/seed.ts文件,功能是:用 Puppeteer 爬取指定环保网站 → 用 LangChain 分块 → 生成向量 → 插入 Supabase。

// scripts/seed.ts
import { createOpenAI } from "@ai-sdk/openai";
import { PuppeteerWebBaseLoader } from '@langchain/community/document_loaders/web/puppeteer';
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
import { embed } from 'ai';
import "dotenv/config";
import { createClient } from '@supabase/supabase-js';

// 1. 初始化Supabase和OpenAI
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
  process.env.NEXT_PUBLIC_SUPABASE_KEY ?? ""
);
const openai = createOpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  baseURL: process.env.OPENAI_API_BASE_URL,
});

// 2. 网页爬取函数(用Puppeteer无头浏览器)
const scrapePage = async (url: string): Promise<string> => {
  const loader = new PuppeteerWebBaseLoader(url, {
    launchOptions: {
      executablePath: 'C:\Program Files\Google\Chrome\Application\chrome.exe', // 本地Chrome路径
      headless: true, // 无头模式(不显示浏览器界面)
    },
    gotoOptions: {
      waitUntil: 'networkidle0', // 等待页面加载完成(网络空闲)
    },
    evaluate: async (page, browser) => {
      const html = await page.evaluate(() => document.body.innerHTML);
      await browser.close(); // 关闭浏览器
      return html.replace(/<[^>]*>?/gm, ""); // 去除HTML标签,保留纯文本
    }
  });
  return loader.scrape();
};

// 3. 数据加载+分块+向量存储
const loadData = async (webpages: string[]) => {
  console.log("开始爬取环保网站...");
  const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 512, // 每个分块512字符(保证语义完整)
    chunkOverlap: 100, // 分块重叠100字符(避免断句)
  });

  for (const url of webpages) {
    try {
      // 爬取网页纯文本
      const content = await scrapePage(url);
      // 文本分块
      const chunks = await splitter.splitText(content);
      console.log(`爬取${url}成功,得到${chunks.length}个分块`);

      // 为每个分块生成向量并插入Supabase
      for (const chunk of chunks) {
        const { embedding } = await embed({
          model: openai.embedding('text-embedding-3-small'),
          value: chunk,
        });

        const { error } = await supabase.from("chunks").insert({
          content: chunk,
          vector: embedding,
          url: url,
        });

        if (error) throw error;
      }
    } catch (err) {
      console.error(`处理${url}失败:`, err);
    }
  }
  console.log("所有数据处理完成!");
};

// 4. 爬取目标环保网站(选高权威、高更新频率的站点)
loadData([
  "https://www.ecowatch.com/", // 环保新闻
  "https://www.unep.org/news-and-stories", // 联合国环境规划署
  "https://www.epa.gov/newsreleases", // 美国环保署
  "https://www.goingzerowaste.com/blog/", // 零浪费生活博客
]);

3. 运行爬虫脚本

package.json中添加脚本:

"scripts": {
  "seed": "ts-node scripts/seed.ts"
}

然后执行:

npm run seed

等待几分钟(取决于网站数量),爬取完成后,Supabase 的chunks表会有上千条数据 —— 这就是 EcoGPT 的 “专业知识库”。

image.png

 第三步:后端 API—— 实现 “检索 + 生成” 逻辑

创建app/api/ecogpt/route.ts文件,这是 EcoGPT 的 “大脑”,负责:

  1. 接收用户问题 → 生成向量
  2. 调用 Supabase RPC 检索相关知识库
  3. 构建 Prompt(结合知识库) → 调用 GPT-4o-mini
  4. 流式返回结果(逐字显示,提升体验)
// app/api/ecogpt/route.ts
import { embed, streamText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { createClient } from '@supabase/supabase-js';
import { NextRequest } from 'next/server';

// 初始化Supabase和OpenAI
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
  process.env.NEXT_PUBLIC_SUPABASE_KEY ?? ""
);
const openai = createOpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  baseURL: process.env.OPENAI_API_BASE_URL,
});

// 1. 生成文本向量
async function generateEmbedding(message: string) {
  return embed({
    model: openai.embedding('text-embedding-3-small'),
    value: message,
  });
}

// 2. 检索相关知识库
async function fetchRelevantContext(embedding: number[]) {
  const { data, error } = await supabase.rpc("get_relevant_chunks", {
    query_vector: embedding,
    match_threshold: 0.7, // 只取相似度≥0.7的内容
    match_count: 5, // 最多取5条(避免Prompt过长)
  });

  if (error) throw error;
  // 格式化上下文(包含来源URL和更新时间,方便用户溯源)
  return JSON.stringify(
    data.map((item: any) => `
      Source: ${item.url},
      Date Updated: ${item.date_updated}
      Content: ${item.content}  
    `)
  );
}

// 3. 构建Prompt模板(核心:约束AI的回答范围和格式)
const createPrompt = (context: string, userQuestion: string) => {
  return {
    role: 'system',
    content: `
      你是EcoGPT,专业的环保生活助手,专注于可持续生活建议。
      
      必须基于以下上下文回答,确保信息准确:
      ----------------
      START CONTEXT
      ${context}
      END CONTEXT
      ----------------
      
      你的专业范围:
      - 气候变化与碳中和
      - 零浪费生活(减量、复用、回收)
      - 绿色科技与可再生能源
      -  ethical消费与环保品牌
      - 各地垃圾分类政策
      
      回答规则:
      1. 只回答环保相关问题,无关问题请礼貌拒绝。
      2. 提供具体可操作的建议(如“用布袋子代替塑料袋”,而非“减少浪费”)。
      3. 本地信息(如回收点)需注明“信息可能更新,建议咨询当地部门”。
      4. 用Markdown格式返回,包含来源链接和更新时间。
      5. 用户做环保行动时,要鼓励(如“太棒了!坚持使用可重复餐具能减少塑料污染”)。
      
      ----------------
      用户问题:${userQuestion}
      ----------------
    `
  };
};

// 4. 处理POST请求(流式返回)
export async function POST(req: NextRequest) {
  try {
    const { messages } = await req.json();
    const latestMessage = messages.at(-1).content; // 获取最新用户问题

    // 生成向量 → 检索上下文 → 构建Prompt
    const { embedding } = await generateEmbedding(latestMessage);
    const context = await fetchRelevantContext(embedding);
    const systemPrompt = createPrompt(context, latestMessage);

    // 调用GPT-4o-mini生成流式结果
    const result = streamText({
      model: openai("gpt-4o-mini"),
      messages: [systemPrompt, ...messages], // 传入系统Prompt和历史消息
    });

    // 转换为数据流响应(前端可接收流式输出)
    return result.toDataStreamResponse();
  } catch (err) {
    console.error("API错误:", err);
    return new Response(JSON.stringify({ error: "服务器内部错误" }), {
      status: 500,
      headers: { "Content-Type": "application/json" },
    });
  }
}

第四步:前端界面 —— 打造流畅的交互体验

前端核心是 “让用户用得爽”,分为 3 个组件:Home(页面容器)、ChatInput(输入框)、ChatOutput(消息展示)。

1. ChatInput 组件(输入框 + 提交按钮)

创建components/ChatInput.tsx,功能:输入问题、提交、加载状态禁用。

// components/ChatInput.tsx
"use client";
import { Input } from '@/components/ui/input'; // 可改用原生input,这里用shadcn组件
import { Button } from '@/components/ui/button';
import { Leaf } from 'lucide-react';

// TS类型约束(避免传参错误)
interface ChatInputProps {
  input: string;
  handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
  handleSubmit: (e: React.FormEvent) => void;
  isLoading: boolean; // 加载状态(禁用输入和按钮)
}

export default function ChatInput({
  input,
  handleInputChange,
  handleSubmit,
  isLoading,
}: ChatInputProps) {
  return (
    <form onSubmit={handleSubmit} className="flex gap-2 items-center">
      <Input
        onChange={handleInputChange}
        value={input}
        placeholder="问我关于零浪费生活、垃圾分类、环保品牌的问题吧!"
        className="flex-1 border-green-300 focus:border-green-500"
        disabled={isLoading} // 加载时禁用输入
      />
      <Button
        type="submit"
        className="bg-green-600 hover:bg-green-700 text-white"
        disabled={!input.trim() || isLoading} // 空输入或加载时禁用按钮
      >
        <Leaf size={18} className="mr-1" /> {/* 环保叶子图标 */}
        <span className="sr-only">提交</span>
      </Button>
    </form>
  );
}

2. ChatOutput 组件(消息展示 + 流式加载 + 自动滚动)

创建components/ChatOutput.tsx,功能:展示用户 / AI 消息、加载状态、空状态提示,以及 “自动滚动到最新消息”。

// components/ChatOutput.tsx
"use client";
import type { Message } from 'ai';
import ReactMarkdown from 'react-markdown';
import { User, Bot } from 'lucide-react';
import { useEffect, useRef } from 'react';

interface ChatOutputProps {
  messages: Message[];
  status: string; // 状态:idle/submitted/error
}

export default function ChatOutput({ messages, status }: ChatOutputProps) {
  const messagesEndRef = useRef<HTMLDivElement>(null); // 用于自动滚动

  // 自动滚动到最新消息
  const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  };
  useEffect(() => scrollToBottom(), [messages, status]); // 消息/状态变了就滚动

  // 空状态(用户还没提问时)
  if (messages.length === 0) {
    return (
      <div className="text-center py-8 text-green-700">
        <Bot size={48} className="mx-auto mb-4 text-green-500" />
        <h2 className="text-xl font-semibold mb-2">欢迎使用EcoGPT!</h2>
        <p>我可以帮你:</p>
        <ul className="list-disc list-inside mt-2 text-left inline-block">
          <li>减少碳足迹</li>
          <li>查询回收点</li>
          <li>选择环保产品</li>
          <li>了解垃圾分类政策</li>
          <li>践行零浪费生活</li>
        </ul>
      </div>
    );
  }

  return (
    <>
      {/* 渲染历史消息 */}
      {messages.map((message, index) =>
        message.role === "user" ? (
          // 用户消息(右对齐)
          <div key={index} className="flex justify-end mb-6">
            <div className="bg-green-100 text-green-900 rounded-2xl px-4 py-3 max-w-[80%]">
              <div className="flex items-center mb-1">
                <User size={16} className="mr-1 text-green-600" />
                <span className="text-xs font-medium text-green-700"></span>
              </div>
              {message.content}
            </div>
          </div>
        ) : (
          // AI消息(左对齐,Markdown渲染)
          <div key={index} className="flex mb-6">
            <div className="bg-blue-50 text-gray-800 rounded-2xl px-4 py-3 max-w-[80%]">
              <div className="flex items-center mb-2">
                <Bot size={16} className="mr-1 text-blue-500" />
                <span className="text-xs font-medium text-blue-700">EcoGPT</span>
              </div>
              <ReactMarkdown
                components={{
                  // 链接打开新窗口
                  a: ({ href, children }) => (
                    <a target="_blank" href={href} className="text-blue-600 hover:underline">
                      {children}
                    </a>
                  ),
                  // 列表样式
                  ul: ({ children }) => <ul className="list-disc list-inside ml-4 mt-2">{children}</ul>,
                }}
              >
                {message.content}
              </ReactMarkdown>
            </div>
          </div>
        )
      )}

      {/* 加载状态(AI思考中) */}
      {status === "submitted" && (
        <div className="flex items-center text-green-600 my-4">
          <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-green-500 mr-2"></div>
          <span>EcoGPT正在思考...</span>
        </div>
      )}

      {/* 错误状态 */}
      {status === "error" && (
        <div className="text-red-500 p-3 bg-red-50 rounded-lg my-4">
          出错了,请稍后再试~
        </div>
      )}

      <div ref={messagesEndRef} /> {/* 滚动定位点 */}
    </>
  );
}

3. Home 页面(整合组件 + 布局)

创建app/page.tsx,这是 EcoGPT 的入口页面,用 Tailwind 实现响应式布局(手机 / PC 都适配)。

// app/page.tsx
"use client";
import { useChat } from '@ai-sdk/react';
import ChatInput from '@/components/ChatInput';
import ChatOutput from '@/components/ChatOutput';

export default function Home() {
  // @ai-sdk/react的useChat hooks:封装了输入、消息、提交逻辑
  const {
    input,
    messages,
    status, // idle/submitted/error
    handleInputChange, // 输入框变化
    handleSubmit, // 提交表单
  } = useChat({
    api: '/api/ecogpt', // 对接后端API
  });

  return (
    <main className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100 py-8 px-4">
      {/* 容器(PC端最大宽度768px,居中;手机端100%宽度) */}
      <div className="max-w-4xl mx-auto bg-white rounded-2xl shadow-lg p-6">
        {/* 标题区 */}
        <div className="text-center mb-6">
          <h1 className="text-3xl font-bold text-green-800 mb-2">EcoGPT</h1>
          <p className="text-green-600">你的环保生活AI助手</p>
          <div className="mt-2 flex justify-center">
            <span className="inline-block w-12 h-1 bg-green-500 rounded-full"></span>
            <span className="inline-block w-4 h-1 bg-green-500 rounded-full mx-1"></span>
            <span className="inline-block w-2 h-1 bg-green-500 rounded-full"></span>
          </div>
        </div>

        {/* 消息区(最大高度60vh,滚动) */}
        <div className="space-y-4 mb-6 max-h-[60vh] overflow-y-auto p-2 rounded-lg bg-green-50">
          <ChatOutput messages={messages} status={status} />
        </div>

        {/* 输入区 */}
        <div className="bg-white rounded-xl p-3 shadow-sm">
          <ChatInput
            input={input}
            handleInputChange={handleInputChange}
            handleSubmit={handleSubmit}
            isLoading={status === "submitted"} // 传递加载状态
          />
        </div>

        {/* 提示文本 */}
        <div className="mt-4 text-center text-sm text-green-700">
          <p>问我关于零浪费生活、碳足迹、回收、环保品牌的问题吧!</p>
        </div>
      </div>
    </main>
  );
}

项目亮点:这些设计让 EcoGPT 更优秀

  1. RAG 技术解决 “专业度” 问题
    不是依赖 LLM 的 “模糊记忆”,而是实时检索权威环保知识库,回答更精准(比如能说出 “2024 年某城市垃圾分类新规”)。
  2. 流式输出提升 “体验感”
    @ai-sdk/reactuseChatstreamText,实现逐字显示,避免用户 “等待空白”,体验接近真人对话。
  3. 响应式布局适配 “多设备”
    Tailwind 的max-w-4xl+mx-auto,在手机上是全屏,在 PC 上是居中窄版,阅读更舒适;max-h-[60vh]避免消息区过长。
  4. Prompt 工程保证 “回答质量”
    明确 AI 的 “身份(EcoGPT)”“专业范围”“回答规则”,避免答非所问;强制包含来源链接,提升可信度。
  5. 组件化与 TS 约束降低 “维护成本”
    拆分ChatInput/ChatOutput组件,逻辑清晰;TS 类型约束(如ChatInputProps)避免传参错误,后期改代码更放心。

五、踩坑与解决方案

  1. AI SDK 版本不兼容导致调试失败
    一开始用的ai SDK 旧版本,streamText返回格式和文档不一致,查了官方文档后更新到最新版(npm update ai),问题解决。

  2. ts-node 运行爬虫提示 “ESM 模块错误”
    原因是tsconfig.jsonmodule设为了ESNext,改为CommonJS后,ts-node 能正常解析。

  3. Supabase RPC 调用返回空数据
    排查发现:① 向量维度不匹配(一开始用了 768 维模型,应该用 1536 维);② RPC 函数的match_threshold设太高(一开始 0.8,改为 0.7 后有数据)。

  4. Puppeteer 启动失败
    本地没有安装 Chrome,或者executablePath路径错误,指定本地 Chrome 的完整路径(如C:\Program Files\Google\Chrome\Application\chrome.exe)后解决。

总结与扩展:EcoGPT 的未来

EcoGPT 目前已经能满足 “基础环保问答” 需求,但还有很多可以优化的方向:

  1. 本地化功能:对接高德 / 百度地图 API,提供 “附近回收点查询”。
  2. 个人化建议:添加 “个人碳足迹计算”,根据用户习惯推荐环保方案。
  3. 多语言支持:增加英文、日文等,服务更多用户。
  4. 知识库更新自动化:用 GitHub Actions 定时运行爬虫,确保知识库实时更新。

这个项目的核心是 “RAG + 专业领域”—— 只要替换知识库(比如换成医疗、法律、教育内容),就能快速打造其他领域的专业 ChatBot,比如 “MedicalGPT”“LawGPT”,复用率非常高。