基于Next.js与AI API构建智能推荐系统:从架构到实践
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提供了几个无法拒绝的优势:
- 一体化开发体验 :我们不需要单独配置后端服务器(如Express)、API路由处理、以及复杂的前端构建配置。Next.js的
app/目录结构天然支持服务端组件(Server Components)和API路由(Route Handlers),所有代码可以放在一个项目里管理,极大地简化了开发和部署。 - 服务端渲染(SSR)与流式渲染 :这对于需要与AI API交互的场景特别有用。AI接口的响应时间可能不稳定,如果放在客户端直接调用,用户会面对漫长的白屏等待。利用Next.js,我们可以在服务端进行AI调用,先渲染出页面骨架或部分内容,再通过流式渲染(Streaming)逐步将AI生成的结果发送到客户端,用户体验会流畅得多。
- 高效的API路由 :在
app/api/目录下创建文件即可定义API端点。我们将在这里创建处理用户查询、调用AI服务的后端逻辑。这些API路由运行在安全的服务端环境,可以方便地处理环境变量(如AI API密钥),避免在客户端暴露敏感信息。 - 强大的工具链与社区 :从热重载、类型检查(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)时,这类平台会非常有用。
本项目的混合架构设计 : 为了兼顾效果、学习成本和鲁棒性,我决定采用一种混合架构:
- 主路径 :用户前端输入 -> Next.js API路由 -> 调用OpenAI API -> 解析返回的JSON -> 前端渲染结果。
- 降级路径 :如果AI API调用失败或超时,则回退到本地的基于标签匹配的推荐算法。
- 数据层 :在项目内维护一个静态的
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路由做了以下几件关键事情:
- 验证输入 :使用Zod库验证请求体,确保安全。
- 提供降级路径 :如果强制使用降级或AI API不可用,则回退到本地标签匹配算法。
- 精心设计Prompt :通过
systemPrompt明确界定了AI的角色、任务和输出格式。使用response_format: { type: "json_object" }能显著提高模型返回规范JSON的稳定性。 - 结果增强 :将AI返回的水果名称,与本地数据库关联,获取更丰富的图片、描述等信息返回给前端。
- 错误处理 :在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是最无缝的体验。
- 推送代码到Git仓库 :在GitHub, GitLab或Bitbucket上创建一个新的仓库,并将本地代码推送上去。
- 登录Vercel :访问 vercel.com ,用GitHub账号登录。
- 导入项目 :点击“Add New” -> “Project”,从你的Git仓库导入这个Next.js项目。
- 配置环境变量 :在项目的设置(Settings)-> Environment Variables页面,添加你在
.env.local中定义的OPENAI_API_KEY。 - 部署 :点击“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 开发过程中的关键“坑点”与解决方案
-
OpenAI API响应格式不稳定
- 问题 :即使我们在Prompt里要求返回JSON,模型有时还是会返回一些非JSON的前缀或后缀,导致
JSON.parse失败。 - 解决 :
- 使用
response_format参数 :这是最有效的方法,但需要模型支持(如gpt-4o,gpt-3.5-turbo-1106及以上)。它能极大提高JSON输出稳定性。 - Prompt工程 :在
systemPrompt中反复强调“必须是严格的JSON格式”,并给出明确的字段示例。 - 后端兜底 :在
try-catch中解析JSON,如果失败,可以尝试用正则表达式从响应文本中提取可能的JSON部分,或者直接触发降级逻辑。
- 使用
- 问题 :即使我们在Prompt里要求返回JSON,模型有时还是会返回一些非JSON的前缀或后缀,导致
-
Next.js服务端组件与客户端组件的边界
- 问题 :在
app/page.tsx中,我们使用了useState和事件处理,所以必须声明‘use client’。但有时我们想在同页面混合使用服务端组件(如直接获取数据)和客户端交互,容易混淆。 - 解决 :清晰规划组件树。将数据获取(如调用API)的逻辑放在服务端组件或API路由中。将交互UI(表单、按钮)放在客户端组件。通过props传递数据。例如,我们的主页是客户端组件,它通过
fetch调用我们自己的/api/recommend服务端API路由。
- 问题 :在
-
TypeScript类型安全
- 问题 :AI返回的数据结构是动态的,直接使用
any类型会失去TypeScript的优势。 - 解决 :使用Zod进行运行时验证。我们在API路由中定义了
RecommendationSchema,确保从AI接收的数据符合预期格式。验证失败时,可以抛出错误或使用默认值,保证程序健壮性。
- 问题 :AI返回的数据结构是动态的,直接使用
-
降级方案的体验
- 问题 :当AI API失败时,直接给用户一个技术错误很不友好。
- 解决 :实现一个无缝的降级方案。我们的API路由在
try-catch的catch块和主逻辑中,都准备了基于本地标签匹配的降级逻辑。并且在前端结果展示中,通过不同的图标和文案(“AI智能推荐” vs “本地智能匹配”)透明地告知用户当前使用的模式,体验更佳。
-
图片优化与占位
- 问题 :使用Unsplash等外链图片,加载速度可能不稳定,且不符合Next.js的最佳实践。
- 解决 :对于生产环境,可以考虑使用Next.js的
next/image组件进行自动图片优化、尺寸调整和懒加载。但需要配置next.config.js的images.remotePatterns允许这些域名。对于Demo,我们使用了简单的<img>标签,但实际项目中强烈推荐使用next/image。
这个项目从构思到实现,让我深刻体会到,借助像Next.js这样的现代全栈框架和成熟的AI API,构建一个具备“智能”交互的应用原型可以如此快速。它不仅仅是一个Demo,更是一个学习如何将前沿AI能力与Web开发工作流结合的绝佳样板。你可以在此基础上无限扩展:增加用户历史记录、实现更复杂的混合推荐算法、接入语音输入、甚至做成一个微信小程序。希望这篇详细的拆解能给你带来启发,动手试试,你会发现创造智能应用,乐趣无穷。
更多推荐
所有评论(0)