1. 项目缘起:从“猜你喜欢”到亲手实现

每次打开淘宝、京东这类电商App,首页那些“猜你喜欢”的商品推荐,总能精准地戳中我的购物欲。作为一个开发者,我既享受这种“被懂”的便利,也对背后的技术充满了好奇。这些推荐是怎么算出来的?它真的理解我的喜好,还是只是简单的规则匹配?最近,随着大模型和AI应用开发工具的普及,构建一个属于自己的、轻量级的智能推荐系统,门槛已经大大降低。这让我萌生了一个想法:能不能不用那些庞大复杂的后端推荐引擎,就用前端最流行的Next.js框架,结合一些现成的AI能力,快速搭建一个能“智能”推荐水果的Demo应用?

这个想法并非空穴来风。Next.js的崛起,尤其是其App Router带来的服务端组件、流式渲染等特性,让前端开发者处理数据获取、服务端逻辑变得前所未有的简单。而AI方面,无论是通过API调用云端大模型,还是使用一些轻量级的机器学习库,都能让我们在浏览器或Node.js环境中嵌入智能。将这两者结合,目标就是打造一个极简的“智能水果铺”:用户输入一些简单的偏好(比如“喜欢甜的”、“怕酸”、“想要补充维生素C”),或者与系统进行几句对话,应用就能推荐出几种可能符合口味的水果,并展示详细信息。

整个项目的核心价值在于“入门”和“演示”。它不追求媲美工业级推荐系统的精度和规模,而是专注于展示如何将Next.js的现代开发模式与AI能力进行有机整合,打通从用户输入、智能理解到结果呈现的完整链路。对于想了解AI应用开发、想学习Next.js全栈开发,或者单纯对推荐系统感兴趣的前端开发者来说,这是一个绝佳的练手项目。你会发现,智能,离我们并没有那么遥远。

2. 技术选型与架构设计:为什么是Next.js + 轻量AI?

在启动任何项目前,明确技术栈和架构是至关重要的一步。这决定了开发体验、项目可维护性和最终效果的上下限。对于这个智能水果推荐Demo,我的选型思路紧紧围绕“全栈前端化”、“快速集成AI”和“良好开发体验”这三个目标。

2.1 为什么选择Next.js作为核心框架?

首先,Next.js是目前React生态中最成熟的全栈框架,没有之一。它为我们这个Demo提供了几个无法拒绝的优势:

  1. 一体化开发体验 :我们不需要单独配置后端服务器(如Express)、API路由处理、以及复杂的前端构建配置。Next.js的 app/ 目录结构天然支持服务端组件(Server Components)和API路由(Route Handlers),所有代码可以放在一个项目里管理,极大地简化了开发和部署。
  2. 服务端渲染(SSR)与流式渲染 :这对于需要与AI API交互的场景特别有用。AI接口的响应时间可能不稳定,如果放在客户端直接调用,用户会面对漫长的白屏等待。利用Next.js,我们可以在服务端进行AI调用,先渲染出页面骨架或部分内容,再通过流式渲染(Streaming)逐步将AI生成的结果发送到客户端,用户体验会流畅得多。
  3. 高效的API路由 :在 app/api/ 目录下创建文件即可定义API端点。我们将在这里创建处理用户查询、调用AI服务的后端逻辑。这些API路由运行在安全的服务端环境,可以方便地处理环境变量(如AI API密钥),避免在客户端暴露敏感信息。
  4. 强大的工具链与社区 :从热重载、类型检查(TypeScript)到静态导出,Next.js提供了一整套开箱即用的工具。丰富的社区资源和插件(例如处理表单的 react-hook-form ,UI库 shadcn/ui )能让我们快速搭建出美观可用的界面。

2.2 AI能力如何嵌入?从云端API到本地模拟

这是项目的智能核心。我们有几种路径可选,各有优劣:

  • 路径一:直接调用大模型API(如OpenAI GPT, Anthropic Claude)

    • 优点 :效果最好,能力最强。大模型能很好地理解自然语言描述,并生成结构化的推荐理由。例如,用户说“今天嗓子疼,想吃点水分多的”,模型可以理解并关联到“梨”、“西瓜”等水果。
    • 缺点 :需要API密钥,可能产生费用;依赖网络,响应速度受API影响;需要处理可能出现的输出格式不稳定问题。
    • 实操选择 :对于Demo,这是最直接体现“智能”的方式。我们将使用OpenAI的GPT-3.5-turbo或GPT-4o-mini这类性价比高的模型。关键在于如何设计提示词(Prompt),让模型返回我们期望的JSON格式数据。
  • 路径二:使用本地轻量级NLP库或向量数据库

    • 优点 :完全离线,无网络延迟和费用;数据隐私性好。
    • 缺点 :实现真正的“语义理解”门槛较高;需要自己构建水果特征库和简单的匹配/排序算法;效果远不如大模型。
    • 实操选择 :作为备选或补充方案。我们可以建立一个本地的小型“水果知识库”,每个水果用一组标签(tags)描述,如 [“甜”, “多汁”, “维生素C高”, “热带”] 。当用户输入时,进行关键词提取和标签匹配。这可以作为一个降级方案,当AI API不可用时提供基础的推荐。
  • 路径三:使用AI应用开发平台(如Dify, LangChain)

    • 优点 :进一步简化了AI集成的流程,提供了可视化的提示词编排、知识库管理等功能。
    • 缺点 :引入了额外的平台依赖,可能增加架构复杂度;对于理解底层原理帮助有限。
    • 实操选择 :在本Demo中暂不采用,因为我们希望更贴近底层API的调用,以学习核心原理。但在构建更复杂的AI智能体(Agent)时,这类平台会非常有用。

本项目的混合架构设计 : 为了兼顾效果、学习成本和鲁棒性,我决定采用一种混合架构:

  1. 主路径 :用户前端输入 -> Next.js API路由 -> 调用OpenAI API -> 解析返回的JSON -> 前端渲染结果。
  2. 降级路径 :如果AI API调用失败或超时,则回退到本地的基于标签匹配的推荐算法。
  3. 数据层 :在项目内维护一个静态的 fruits.json 文件,作为我们的“水果数据库”,包含每种水果的名称、图片、描述、标签、价格模拟数据等。

2.3 前端UI与状态管理

UI方面,为了快速搭建一个清爽美观的界面,我会选择 Tailwind CSS 配合一些预制组件。状态管理主要涉及用户输入、加载状态和推荐结果,由于交互相对简单,使用React的 useState 和 useEffect 钩子,结合服务端数据获取(如 fetch )完全足够。对于表单, react-hook-form 是个不错的选择。

最终的架构流程图在脑海中是这样的:用户访问Next.js页面 -> 页面提供输入表单 -> 提交表单触发API路由 -> API路由构造Prompt调用AI -> AI返回结果 -> API路由解析并查询本地数据库补充详细信息 -> 结果流式或一次性返回前端 -> 前端渲染推荐卡片。

3. 一步步搭建项目骨架与核心数据层

理论说得再多,不如动手敲代码。让我们从初始化项目开始,一步步构建这个智能水果推荐Demo的基石。

3.1 初始化Next.js项目与环境配置

首先,确保你的Node.js版本在18.17或以上。打开终端,执行以下命令创建新的Next.js项目:

npx create-next-app@latest fruit-ai-recommender

在创建过程中,CLI会交互式地询问一些配置。我的选择如下:

  • TypeScript : Yes (强烈推荐,类型安全对AI应用的数据结构很重要)
  • ESLint : Yes
  • Tailwind CSS : Yes (用于快速样式开发)
  • App Router : Yes (我们采用新的App Router,而非Pages Router)
  • Import alias (@/*) : Yes (保持默认)

项目创建完成后,进入目录并安装一些额外的依赖:

cd fruit-ai-recommender
npm install openai  # OpenAI官方Node.js SDK
npm install react-hook-form  # 处理表单
npm install zod  # 用于数据验证,特别是在API入参和出参时
npm install @radix-ui/react-icons  # 一套简洁的图标库

接下来,配置环境变量。在项目根目录创建 .env.local 文件,用于存储敏感信息:

# .env.local
OPENAI_API_KEY=你的OpenAI_API密钥

注意 : .env.local 文件务必添加到 .gitignore 中,切勿提交到代码仓库。你的API密钥是私密的。

3.2 构建本地“水果数据库”

在 /lib 目录下(如果没有就创建一个),我们创建 data.ts 和 fruits.json 。

首先,定义水果的数据类型( /lib/types.ts ):

// /lib/types.ts
export interface Fruit {
  id: string;
  name: string;
  description: string;
  tags: string[]; // 特征标签,如 ["甜", "多汁", "富含维C", "夏季"]
  pricePerKg: number; // 模拟价格
  imageUrl: string; // 可以使用Unsplash等免费图库的图片链接
  nutritionHighlight?: string; // 营养亮点
}

然后,创建我们的静态数据库( /lib/fruits.json ):

[
  {
    "id": "1",
    "name": "新疆哈密瓜",
    "description": "果肉细腻,香甜多汁,糖分高,是夏季消暑佳品。",
    "tags": ["甜", "多汁", "夏季", "解暑", "高糖"],
    "pricePerKg": 15.8,
    "imageUrl": "https://images.unsplash.com/photo-1629084092239-e9c8c...",
    "nutritionHighlight": "富含维生素A和C,水分含量超过90%。"
  },
  {
    "id": "2",
    "name": "百香果",
    "description": "香气浓郁,口感酸甜,籽可食用,常用于制作饮品。",
    "tags": ["酸", "香", "热带", "维C高", "开胃"],
    "pricePerKg": 32.0,
    "imageUrl": "https://images.unsplash.com/photo-1553279768-...",
    "nutritionHighlight": "维生素C和膳食纤维的优质来源,抗氧化性强。"
  },
  {
    "id": "3",
    "name": "蓝莓",
    "description": "颗粒小巧,口感酸甜,富含花青素,被誉为“超级水果”。",
    "tags": ["甜", "酸", "抗氧化", "小巧", "健康"],
    "pricePerKg": 80.0,
    "imageUrl": "https://images.unsplash.com/photo-1498557850523-...",
    "nutritionHighlight": "花青素含量极高,对眼睛和心血管健康有益。"
  },
  // ... 可以继续添加更多,如西瓜、芒果、榴莲、苹果、梨等,总共15-20种
]

最后,创建一个数据获取函数( /lib/data.ts ):

// /lib/data.ts
import fruitsData from './fruits.json';
import { Fruit } from './types';

export async function getAllFruits(): Promise<Fruit[]> {
  // 这里模拟异步获取,实际就是读取本地JSON
  return Promise.resolve(fruitsData);
}

export async function getFruitsByTags(tags: string[]): Promise<Fruit[]> {
  const allFruits = await getAllFruits();
  if (tags.length === 0) return allFruits;

  // 简单的标签匹配算法:计算每个水果的匹配分数
  const scoredFruits = allFruits.map(fruit => {
    const matchScore = tags.filter(tag => fruit.tags.includes(tag)).length;
    return { ...fruit, matchScore };
  });

  // 按匹配分数降序排序,返回分数大于0的
  return scoredFruits
    .filter(f => f.matchScore > 0)
    .sort((a, b) => b.matchScore - a.matchScore);
}

export async function getFruitById(id: string): Promise<Fruit | undefined> {
  const allFruits = await getAllFruits();
  return allFruits.find(fruit => fruit.id === id);
}

这个本地数据库和匹配算法,构成了我们的“降级方案”和AI推荐结果的详情补充来源。

4. 实现智能推荐的核心:Next.js API路由与AI集成

这是整个项目最有趣也最具挑战性的部分。我们将创建一个API路由,它接收用户的自然语言描述,调用OpenAI API,并返回结构化的推荐结果。

4.1 创建AI推荐API端点

在 app/api/ 目录下创建文件 app/api/recommend/route.ts 。这是Next.js App Router中定义API路由的标准方式。

// app/api/recommend/route.ts
import { NextRequest, NextResponse } from 'next/server';
import OpenAI from 'openai';
import { z } from 'zod';
import { getFruitsByTags, getAllFruits } from '@/lib/data';

// 初始化OpenAI客户端,它会自动从环境变量OPENAI_API_KEY读取密钥
const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

// 定义我们期望AI返回的JSON结构
const RecommendationSchema = z.object({
  reasoning: z.string().describe("AI进行推荐的简要推理过程"),
  recommendedFruitNames: z.array(z.string()).min(1).max(5).describe("推荐的水果名称列表"),
  matchedTags: z.array(z.string()).describe("从用户描述中提取出的关键特征标签"),
});

// 定义请求体的结构
const RequestBodySchema = z.object({
  userInput: z.string().min(1).max(500),
  useFallback: z.boolean().optional().default(false), // 是否强制使用降级方案
});

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { userInput, useFallback } = RequestBodySchema.parse(body);

    // **降级方案:如果强制使用或API密钥未设置,则使用本地标签匹配**
    if (useFallback || !process.env.OPENAI_API_KEY) {
      console.log('Using fallback recommendation logic.');
      // 这里需要一个简单的从用户输入提取关键词的函数(示例,实际可更复杂)
      const extractedTags = extractTagsFromInput(userInput);
      const localFruits = await getFruitsByTags(extractedTags);
      return NextResponse.json({
        source: 'fallback',
        fruits: localFruits.slice(0, 3), // 返回匹配度最高的3种
        extractedTags,
      });
    }

    // **主路径:调用OpenAI API**
    const systemPrompt = `你是一个专业的水果营养师和推荐专家。根据用户的描述,推荐最合适的水果。
    请遵循以下规则:
    1. 仔细分析用户的描述,理解其口味偏好(甜/酸)、身体状况需求(补水/补充维C/上火)、场景(送礼/自己吃)等。
    2. 从以下水果列表中推荐1-5种水果:${(await getAllFruits()).map(f => f.name).join(', ')}。
    3. 你的回复必须是严格的JSON格式,包含三个字段:
       - "reasoning": 字符串,简要说明你的推荐理由。
       - "recommendedFruitNames": 数组,包含推荐的水果名称字符串。
       - "matchedTags": 数组,包含你从用户描述中识别出的关键特征标签,如 ["甜", "补水", "夏季"]。
    4. 确保推荐的水果名称必须完全匹配我提供列表中的名称。`;

    const userPrompt = `用户描述:"${userInput}"`;

    const completion = await openai.chat.completions.create({
      model: "gpt-4o-mini", // 或 "gpt-3.5-turbo",性价比高
      messages: [
        { role: "system", content: systemPrompt },
        { role: "user", content: userPrompt },
      ],
      response_format: { type: "json_object" }, // 强制返回JSON,这是GPT-4o/3.5-turbo-1106及以上版本支持的特性
      temperature: 0.7, // 控制创造性,0.7在确定性和多样性间取得平衡
    });

    const aiResponse = completion.choices[0]?.message?.content;
    if (!aiResponse) {
      throw new Error('AI响应为空');
    }

    const parsedResult = RecommendationSchema.parse(JSON.parse(aiResponse));

    // 根据AI返回的水果名称,从本地数据库获取完整信息
    const recommendedFruitsPromises = parsedResult.recommendedFruitNames.map(name =>
      getAllFruits().then(fruits => fruits.find(f => f.name === name))
    );
    const fruits = (await Promise.all(recommendedFruitsPromises)).filter(Boolean); // 过滤掉未找到的

    return NextResponse.json({
      source: 'ai',
      reasoning: parsedResult.reasoning,
      fruits,
      matchedTags: parsedResult.matchedTags,
    });

  } catch (error) {
    console.error('推荐API错误:', error);
    // 发生任何错误,也尝试使用降级方案
    const extractedTags = extractTagsFromInput(body?.userInput || '');
    const localFruits = await getFruitsByTags(extractedTags);
    return NextResponse.json(
      {
        source: 'error_fallback',
        fruits: localFruits.slice(0, 3),
        extractedTags,
        error: error instanceof Error ? error.message : '未知错误',
      },
      { status: 500 }
    );
  }
}

// 一个简单的关键词提取函数(用于降级方案)
function extractTagsFromInput(input: string): string[] {
  const allPossibleTags = ['甜', '酸', '多汁', '解暑', '维C高', '抗氧化', '热带', '夏季', '开胃', '健康'];
  const lowerInput = input.toLowerCase();
  return allPossibleTags.filter(tag => lowerInput.includes(tag.toLowerCase()));
}

这个API路由做了以下几件关键事情:

  1. 验证输入 :使用Zod库验证请求体,确保安全。
  2. 提供降级路径 :如果强制使用降级或AI API不可用,则回退到本地标签匹配算法。
  3. 精心设计Prompt :通过 systemPrompt 明确界定了AI的角色、任务和输出格式。使用 response_format: { type: "json_object" } 能显著提高模型返回规范JSON的稳定性。
  4. 结果增强 :将AI返回的水果名称,与本地数据库关联,获取更丰富的图片、描述等信息返回给前端。
  5. 错误处理 :在try-catch中包裹,即使AI调用失败,也尽可能返回降级结果,保证用户体验不崩溃。

4.2 流式响应优化

上面的API是一次性返回结果。如果AI推理时间较长,用户可能需要等待。我们可以利用Next.js和Vercel AI SDK实现流式响应,让推理过程像ChatGPT一样逐字输出。这里为了简化,我们先采用一次性响应。但了解这个优化方向很重要:使用 openai.chat.completions.create 时设置 stream: true ,并在API路由中返回一个 ReadableStream 。

5. 构建交互式前端页面

有了强大的后端API,现在我们来构建用户直接交互的页面。我们将创建一个主页,包含一个输入框、一个提交按钮和一个展示推荐结果的区域。

5.1 创建主页组件

文件位于 app/page.tsx 。

// app/page.tsx
'use client'; // 因为要用到状态和事件处理,所以声明为客户端组件

import { useState } from 'react';
import RecommendationForm from '@/components/RecommendationForm';
import RecommendationResult from '@/components/RecommendationResult';
import { Fruit } from '@/lib/types';

// 定义API返回结果的类型
interface ApiRecommendationResponse {
  source: 'ai' | 'fallback' | 'error_fallback';
  reasoning?: string;
  fruits: Fruit[];
  matchedTags?: string[];
  error?: string;
}

export default function HomePage() {
  const [recommendations, setRecommendations] = useState<ApiRecommendationResponse | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleRecommend = async (userInput: string) => {
    if (!userInput.trim()) {
      setError('请输入您的需求或偏好~');
      return;
    }

    setIsLoading(true);
    setError(null);
    setRecommendations(null);

    try {
      const response = await fetch('/api/recommend', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ userInput }),
      });

      if (!response.ok) {
        throw new Error(`请求失败: ${response.status}`);
      }

      const data: ApiRecommendationResponse = await response.json();
      setRecommendations(data);
    } catch (err) {
      console.error('获取推荐失败:', err);
      setError('抱歉,推荐系统暂时开小差了,请稍后再试。');
      setRecommendations(null);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div className="min-h-screen bg-gradient-to-br from-green-50 to-cyan-50 p-4 md:p-8">
      <div className="max-w-6xl mx-auto">
        {/* 页眉 */}
        <header className="text-center mb-10 pt-8">
          <h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
            智能水果推荐小助手
          </h1>
          <p className="text-lg text-gray-600 max-w-2xl mx-auto">
            告诉我你的口味偏好或需求吧!比如“喜欢甜的”、“天气热想补水”、“补充维生素”,我会用AI为你挑选最合适的水果。
          </p>
          <p className="text-sm text-gray-500 mt-2">
            (本Demo融合了AI大模型与规则匹配,偶尔会使用本地智能降级方案)
          </p>
        </header>

        <main className="grid grid-cols-1 lg:grid-cols-3 gap-8">
          {/* 左侧输入区 */}
          <div className="lg:col-span-2">
            <div className="bg-white rounded-2xl shadow-xl p-6 md:p-8">
              <RecommendationForm onSubmit={handleRecommend} isLoading={isLoading} />
              {error && (
                <div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
                  <p>{error}</p>
                </div>
              )}
            </div>

            {/* 结果展示区 */}
            <div className="mt-8">
              {recommendations && (
                <RecommendationResult data={recommendations} isLoading={isLoading} />
              )}
            </div>
          </div>

          {/* 右侧水果知识小贴士 */}
          <div className="bg-white rounded-2xl shadow-xl p-6 h-fit">
            <h2 className="text-2xl font-bold text-gray-800 mb-4">🍓 水果知识库</h2>
            <ul className="space-y-3">
              <li className="flex items-start">
                <span className="text-green-500 mr-2">•</span>
                <span><strong>甜度代表</strong>:哈密瓜、荔枝、芒果</span>
              </li>
              <li className="flex items-start">
                <span className="text-green-500 mr-2">•</span>
                <span><strong>维C之王</strong>:鲜枣、猕猴桃、草莓、橙子</span>
              </li>
              <li className="flex items-start">
                <span className="text-green-500 mr-2">•</span>
                <span><strong>补水能手</strong>:西瓜、梨、葡萄</span>
              </li>
              <li className="flex items-start">
                <span className="text-green-500 mr-2">•</span>
                <span><strong>低卡选择</strong>:蓝莓、柚子、木瓜</span>
              </li>
            </ul>
            <div className="mt-6 p-4 bg-blue-50 rounded-lg">
              <p className="text-sm text-blue-800">
                <strong>提示:</strong>描述越具体,AI推荐越精准哦!试试“嗓子疼想吃点润喉的”或“下午茶想要颜值高又好吃的”。
              </p>
            </div>
          </div>
        </main>
      </div>
    </div>
  );
}

5.2 创建表单组件

创建 components/RecommendationForm.tsx 。

// components/RecommendationForm.tsx
'use client';

import { useState } from 'react';
import { Send } from 'lucide-react'; // 需要安装 lucide-react: npm install lucide-react

interface RecommendationFormProps {
  onSubmit: (input: string) => void;
  isLoading: boolean;
}

export default function RecommendationForm({ onSubmit, isLoading }: RecommendationFormProps) {
  const [input, setInput] = useState('');
  const [exampleIndex, setExampleIndex] = useState(0);

  const examples = [
    '天气好热,想吃点水分多又解渴的',
    '我喜欢酸酸甜甜的味道',
    '最近熬夜多,想补充点维生素',
    '买给小朋友吃,要甜一点的',
    '嗓子不太舒服,有什么水果可以润喉吗?',
  ];

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    onSubmit(input);
  };

  const handleUseExample = () => {
    setInput(examples[exampleIndex]);
    setExampleIndex((prev) => (prev + 1) % examples.length); // 循环使用示例
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-6">
      <div>
        <label htmlFor="userInput" className="block text-lg font-medium text-gray-700 mb-2">
          描述你的需求
        </label>
        <textarea
          id="userInput"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="例如:想要甜而不腻、适合夏天吃的水果..."
          className="w-full h-32 p-4 border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-green-500 focus:border-transparent text-lg resize-none"
          disabled={isLoading}
        />
        <div className="mt-2 flex justify-between items-center text-sm text-gray-500">
          <span>试试看:</span>
          <button
            type="button"
            onClick={handleUseExample}
            className="text-green-600 hover:text-green-800 underline"
            disabled={isLoading}
          >
            “{examples[exampleIndex]}”
          </button>
        </div>
      </div>

      <div className="flex flex-col sm:flex-row gap-4">
        <button
          type="submit"
          disabled={isLoading || !input.trim()}
          className="flex-1 inline-flex justify-center items-center gap-2 bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 text-white font-semibold py-3 px-6 rounded-xl shadow-md transition-all disabled:opacity-50 disabled:cursor-not-allowed"
        >
          {isLoading ? (
            <>
              <svg className="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
                <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
              </svg>
              思考中...
            </>
          ) : (
            <>
              <Send size={20} />
              开始智能推荐
            </>
          )}
        </button>
        <button
          type="button"
          onClick={() => setInput('')}
          disabled={isLoading}
          className="px-6 py-3 border border-gray-300 text-gray-700 font-medium rounded-xl hover:bg-gray-50 transition-colors disabled:opacity-50"
        >
          清空
        </button>
      </div>
    </form>
  );
}

5.3 创建结果展示组件

创建 components/RecommendationResult.tsx 。

// components/RecommendationResult.tsx
import { Fruit } from '@/lib/types';
import { Bot, Cpu } from 'lucide-react';

interface RecommendationResultProps {
  data: {
    source: 'ai' | 'fallback' | 'error_fallback';
    reasoning?: string;
    fruits: Fruit[];
    matchedTags?: string[];
    error?: string;
  };
  isLoading: boolean;
}

export default function RecommendationResult({ data, isLoading }: RecommendationResultProps) {
  const { source, reasoning, fruits, matchedTags } = data;

  if (isLoading) {
    return null; // 加载状态由父组件按钮处理
  }

  if (fruits.length === 0) {
    return (
      <div className="text-center p-8 bg-yellow-50 rounded-2xl border border-yellow-200">
        <p className="text-lg text-yellow-800">没有找到完全匹配的水果呢。试试换个描述?</p>
      </div>
    );
  }

  return (
    <div className="bg-white rounded-2xl shadow-xl p-6 md:p-8">
      {/* 结果来源标识 */}
      <div className="flex items-center justify-between mb-6 pb-4 border-b">
        <div className="flex items-center gap-2">
          {source === 'ai' ? (
            <>
              <Bot className="text-purple-600" size={24} />
              <span className="text-xl font-bold text-gray-800">AI智能推荐</span>
            </>
          ) : (
            <>
              <Cpu className="text-blue-600" size={24} />
              <span className="text-xl font-bold text-gray-800">本地智能匹配</span>
              <span className="text-sm text-gray-500 ml-2">(AI服务暂不可用)</span>
            </>
          )}
        </div>
        {matchedTags && matchedTags.length > 0 && (
          <div className="flex flex-wrap gap-2">
            <span className="text-sm text-gray-500">识别标签:</span>
            {matchedTags.map(tag => (
              <span key={tag} className="px-3 py-1 bg-green-100 text-green-800 text-sm font-medium rounded-full">
                {tag}
              </span>
            ))}
          </div>
        )}
      </div>

      {/* AI推理过程 */}
      {reasoning && source === 'ai' && (
        <div className="mb-8 p-4 bg-purple-50 border border-purple-100 rounded-xl">
          <h3 className="font-semibold text-purple-800 mb-2">🤔 推荐思路</h3>
          <p className="text-gray-700">{reasoning}</p>
        </div>
      )}

      {/* 推荐水果列表 */}
      <h3 className="text-2xl font-bold text-gray-800 mb-6">为你推荐:</h3>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {fruits.map((fruit) => (
          <div
            key={fruit.id}
            className="border border-gray-200 rounded-2xl overflow-hidden hover:shadow-lg transition-shadow duration-300"
          >
            {/* 水果图片 */}
            <div className="h-48 overflow-hidden bg-gray-100">
              <img
                src={fruit.imageUrl}
                alt={fruit.name}
                className="w-full h-full object-cover hover:scale-105 transition-transform duration-500"
              />
            </div>
            {/* 水果信息 */}
            <div className="p-5">
              <div className="flex justify-between items-start mb-3">
                <h4 className="text-xl font-bold text-gray-900">{fruit.name}</h4>
                <span className="text-lg font-bold text-green-600">¥{fruit.pricePerKg.toFixed(1)}/kg</span>
              </div>
              <p className="text-gray-600 mb-4 line-clamp-2">{fruit.description}</p>
              {/* 标签 */}
              <div className="flex flex-wrap gap-2 mb-4">
                {fruit.tags.map(tag => (
                  <span
                    key={tag}
                    className="px-2 py-1 bg-gray-100 text-gray-700 text-xs font-medium rounded-md"
                  >
                    {tag}
                  </span>
                ))}
              </div>
              {/* 营养亮点 */}
              {fruit.nutritionHighlight && (
                <div className="pt-4 border-t border-gray-100">
                  <p className="text-sm text-gray-700">
                    <span className="font-semibold">营养亮点:</span>
                    {fruit.nutritionHighlight}
                  </p>
                </div>
              )}
            </div>
          </div>
        ))}
      </div>

      {/* 小提示 */}
      <div className="mt-8 text-center text-sm text-gray-500">
        <p>推荐结果仅供参考,实际购买请以市场为准。享受健康水果生活!</p>
      </div>
    </div>
  );
}

6. 部署上线与踩坑实录

项目开发完成,最终要让它跑在线上,供人访问。同时,回顾整个开发过程,我也踩过一些坑,这里分享出来,希望能帮你绕开。

6.1 部署到Vercel(最简方案)

Next.js应用部署到Vercel是最无缝的体验。

  1. 推送代码到Git仓库 :在GitHub, GitLab或Bitbucket上创建一个新的仓库,并将本地代码推送上去。
  2. 登录Vercel :访问 vercel.com ,用GitHub账号登录。
  3. 导入项目 :点击“Add New” -> “Project”,从你的Git仓库导入这个Next.js项目。
  4. 配置环境变量 :在项目的设置(Settings)-> Environment Variables页面,添加你在 .env.local 中定义的 OPENAI_API_KEY 。
  5. 部署 :点击“Deploy”。Vercel会自动检测这是Next.js项目,并运行构建命令。通常一两分钟,你的应用就上线了,会获得一个 *.vercel.app 的域名。

部署注意事项 :

  • 构建错误 :确保你的 next.config.js 没有特殊配置冲突。如果使用了 sharp 等图片优化库,Vercel会自动安装。
  • API路由超时 :Vercel免费计划的Serverless Function默认有10秒的执行超时限制。如果AI API响应慢,可能导致504错误。可以考虑:
    • 优化Prompt,让AI回复更简洁。
    • 使用流式响应,让用户感知上更快。
    • 升级到Pro计划以获得更长的超时时间。
  • 环境变量 :确保生产环境的环境变量已正确设置,且名称与代码中 process.env.OPENAI_API_KEY 引用的一致。

6.2 开发过程中的关键“坑点”与解决方案

  1. OpenAI API响应格式不稳定

    • 问题 :即使我们在Prompt里要求返回JSON,模型有时还是会返回一些非JSON的前缀或后缀,导致 JSON.parse 失败。
    • 解决 :
      • 使用 response_format 参数 :这是最有效的方法,但需要模型支持(如 gpt-4o , gpt-3.5-turbo-1106 及以上)。它能极大提高JSON输出稳定性。
      • Prompt工程 :在 systemPrompt 中反复强调“必须是严格的JSON格式”,并给出明确的字段示例。
      • 后端兜底 :在 try-catch 中解析JSON,如果失败,可以尝试用正则表达式从响应文本中提取可能的JSON部分,或者直接触发降级逻辑。
  2. Next.js服务端组件与客户端组件的边界

    • 问题 :在 app/page.tsx 中,我们使用了 useState 和事件处理,所以必须声明 ‘use client’ 。但有时我们想在同页面混合使用服务端组件(如直接获取数据)和客户端交互,容易混淆。
    • 解决 :清晰规划组件树。将数据获取(如调用API)的逻辑放在服务端组件或API路由中。将交互UI(表单、按钮)放在客户端组件。通过props传递数据。例如,我们的主页是客户端组件,它通过 fetch 调用我们自己的 /api/recommend 服务端API路由。
  3. TypeScript类型安全

    • 问题 :AI返回的数据结构是动态的,直接使用 any 类型会失去TypeScript的优势。
    • 解决 :使用Zod进行运行时验证。我们在API路由中定义了 RecommendationSchema ,确保从AI接收的数据符合预期格式。验证失败时,可以抛出错误或使用默认值,保证程序健壮性。
  4. 降级方案的体验

    • 问题 :当AI API失败时,直接给用户一个技术错误很不友好。
    • 解决 :实现一个无缝的降级方案。我们的API路由在 try-catch 的 catch 块和主逻辑中,都准备了基于本地标签匹配的降级逻辑。并且在前端结果展示中,通过不同的图标和文案(“AI智能推荐” vs “本地智能匹配”)透明地告知用户当前使用的模式,体验更佳。
  5. 图片优化与占位

    • 问题 :使用Unsplash等外链图片,加载速度可能不稳定,且不符合Next.js的最佳实践。
    • 解决 :对于生产环境,可以考虑使用Next.js的 next/image 组件进行自动图片优化、尺寸调整和懒加载。但需要配置 next.config.js 的 images.remotePatterns 允许这些域名。对于Demo,我们使用了简单的 <img> 标签,但实际项目中强烈推荐使用 next/image 。

这个项目从构思到实现,让我深刻体会到,借助像Next.js这样的现代全栈框架和成熟的AI API,构建一个具备“智能”交互的应用原型可以如此快速。它不仅仅是一个Demo,更是一个学习如何将前沿AI能力与Web开发工作流结合的绝佳样板。你可以在此基础上无限扩展:增加用户历史记录、实现更复杂的混合推荐算法、接入语音输入、甚至做成一个微信小程序。希望这篇详细的拆解能给你带来启发,动手试试,你会发现创造智能应用,乐趣无穷。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐