Next.js + AI 实战:从零做一个极简智能摘要工具,三天上线 Product Hunt
来源:互联网
时间:2026-06-09 07:32:07
上个月我做了一个小工具:粘贴一篇长文,AI 帮你生成三句话摘要。
没有复杂的后台管理系统,没有用户体系,没有付费墙。就一个输入框,一个按钮,一段漂亮的输出。
三天开发,第四天上线 Product Hunt。当天拿了 Top 5。
这篇文章完整记录技术实现过程。代码不多,但每一行都是反复删减后留下的。
一、产品设计:少即是多
1.1 功能清单
整个产品只有三个功能:
- 粘贴或输入长文本
- 点击按钮,AI 生成三句话摘要
- 一键复制摘要
没了。就这些。
graph LRA["用户输入长文"] --> B["调用 AI API"]B --> C["流式输出摘要"]C --> D["一键复制"]style B fill:#8b5cf6,color:#fff
1.2 技术选型
| 层 | 选择 | 理由 |
|---|---|---|
| 框架 | Next.js 14 (App Router) | 前后端一体,Route Handler 直接当 API |
| 样式 | CSS Modules | 不引入额外依赖,够用就好 |
| AI | OpenAI gpt-4o-mini | 便宜、快、摘要质量够 |
| 部署 | Vercel | 零配置,推完代码自动上线 |
独立开发的一个原则:能少引一个依赖就少引一个。每多一个 npm install,就多一份未来的维护债。
二、核心实现
2.1 项目结构
summarizer/├── app/│ ├── layout.tsx # 全局布局│ ├── page.tsx # 首页(唯一页面)│ ├── page.module.css # 首页样式│ └── api/│ └── summarize/│ └── route.ts # AI 摘要接口├── components/│ ├── TextInput.tsx # 输入框组件│ ├── SummaryOutput.tsx # 摘要输出组件│ └── CopyButton.tsx # 复制按钮├── lib/│ └── openai.ts # OpenAI 封装└── package.json
一共 8 个文件。每个文件都承担了明确的职责,不多不少。
2.2 后端:流式 AI 接口
// app/api/summarize/route.tsimport { NextRequest } from 'next/server';export async function POST(req: NextRequest) { const { text } = await req.json(); // 输入校验:不废话,直接拦 if (!text || text.length < 50) { return new Response(JSON.stringify({ error: '文本太短,至少 50 个字' }), { status: 400 }); } if (text.length > 10000) { return new Response(JSON.stringify({ error: '文本太长,最多 10000 个字' }), { status: 400 }); } // 调用 OpenAI 流式接口 const response = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`, }, body: JSON.stringify({ model: 'gpt-4o-mini', stream: true, temperature: 0.3, // 摘要场景用低温度,输出更稳定 messages: [ { role: 'system', content: '你是一个专业的文本摘要助手。请用三句话概括用户提供的文章核心内容。要求:第一句点明主题,第二句总结关键论点,第三句给出结论或启发。语言精炼,不要套话。', }, { role: 'user', content: `请摘要以下文章:${text}`, }, ], }), }); // 直接透传 SSE 流给前端 return new Response(response.body, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }, });}
这里直接把 OpenAI 的 SSE 流透传给前端。不做中间缓存,不做二次封装。数据流向越短,延迟越低。
2.3 前端:流式渲染
// components/SummaryOutput.tsx'use client';import { useState } from 'react';import styles from './SummaryOutput.module.css';interface Props { text: string; onComplete: (summary: string) => void;}export default function SummaryOutput({ text, onComplete }: Props) { const [summary, setSummary] = useState(''); const [loading, setLoading] = useState(false); const handleSummarize = async () => { setLoading(true); setSummary(''); const response = await fetch('/api/summarize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), }); if (!response.ok || !response.body) { setSummary('摘要生成失败,请重试'); setLoading(false); return; } // 流式读取 SSE const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let fullText = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; // 最后一行可能不完整,留到下次 for (const line of lines) { if (line === 'data: [DONE]') continue; if (!line.startsWith('data: ')) continue; try { const json = JSON.parse(line.slice(6)); const content = json.choices?.[0]?.delta?.content || ''; fullText += content; setSummary(fullText); // 逐字更新,打字机效果 } catch { // 跳过解析失败的行 } } } setLoading(false); onComplete(fullText); }; return ( {summary && ( {summary}
{!loading && } )} );}
流式读取 SSE 并逐字更新,打造打字机效果,让用户实时看到 AI 在“思考输出”。
2.4 一键复制
// components/CopyButton.tsx'use client';import { useState } from 'react';import styles from './CopyButton.module.css';export default function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); const handleCopy = async () => { try { await na vigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { // 降级方案:用 textarea 选中复制 const textarea = document.createElement('textarea'); textarea.value = text; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); setCopied(true); setTimeout(() => setCopied(false), 2000); } }; if (!text) return null; return (