先看效果吧
实现过程:
1.创建项目
npx create-next-app@latest my-ai-app
cd my-ai-app
- 安装依赖
npm install @langchain/openai @langchain/core
3.添加 .env.local
我用的是千问的,有免费的,直接去薅羊毛拿到密钥放进来就好了
OPENAI_API_KEY=你的密钥
4.弄个route文件代码如下,参照最下面图里新建文件夹哦
import { NextRequest } from 'next/server';
import { ChatOpenAI } from '@langchain/openai';
import { StringOutputParser } from '@langchain/core/output_parsers';
export async function POST(req: NextRequest) {
try {
const { message } = await req.json();
if (!message) {
return new Response(JSON.stringify({ error: '缺少 message' }), { status: 400 });
}
const llm = new ChatOpenAI({
model: 'qwen-turbo',
temperature: 0.7,
apiKey: process.env.DASHSCOPE_API_KEY,
configuration: {
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
},
});
// 关键:用 StringOutputParser 确保只返回文本,不返回对象
const chain = llm.pipe(new StringOutputParser());
const stream = await chain.stream([
{
role: 'system',
content: '你需要严格输出标准的Markdown格式:代码必须用```语言名包裹,标题用#,列表用-,保持排版清晰。不要输出多余的解释性文字,也不要出现重复的内容。'
},
{ role: 'user', content: message }
]);
// 正确的 SSE 格式,只发送文本片段
return new Response(
new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
// 这里的 chunk 已经是纯文本,直接发送
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ content: chunk })}\n\n`));
}
controller.enqueue(new TextEncoder().encode(`data: [DONE]\n\n`));
controller.close();
},
}),
{
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
}
);
} catch (err: any) {
console.error('LLM Error:', err);
return new Response(
JSON.stringify({ error: 'AI 调用失败', detail: err.message }),
{ status: 500 }
);
}
}
5.在弄个页面,参照最下面图里新建文件夹哦
import { useState, useRef, useEffect } from 'react';
import { marked } from 'marked';
interface Message {
role: 'user' | 'assistant';
content: string;
}
marked.setOptions({
breaks: true,
gfm: true,
});
export default function Home() {
const [input, setInput] = useState('');
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const chatEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const sendMessage = async () => {
if (!input.trim() || isLoading) return;
const userMessage: Message = { role: 'user', content: input };
setMessages(prev => [...prev, userMessage]);
setInput('');
setIsLoading(true);
// 初始化AI消息
const aiMessage: Message = { role: 'assistant', content: '' };
setMessages(prev => [...prev, aiMessage]);
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: input }),
});
if (!response.ok) throw new Error('请求失败');
const reader = response.body?.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader!.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n\n').filter(line => line.startsWith('data: '));
for (const line of lines) {
const dataStr = line.replace('data: ', '');
if (dataStr === '[DONE]') continue;
try {
const data = JSON.parse(dataStr);
// 关键:只拼接 content 字段的文本,避免对象直接拼接
if (data.content) {
setMessages(prev => {
const newMessages = [...prev];
newMessages[newMessages.length - 1].content += data.content;
return newMessages;
});
}
} catch (e) {
// 忽略解析错误的片段
continue;
}
}
}
} catch (error) {
console.error(error);
setMessages(prev => {
const newMessages = [...prev];
newMessages[newMessages.length - 1].content = '抱歉,请求出错了,请重试。';
return newMessages;
});
} finally {
setIsLoading(false);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', backgroundColor: '#f7f8fa', margin: 0, padding: 0, fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' }}>
{/* 顶部导航栏 */}
<header style={{ backgroundColor: '#ffffff', borderBottom: '1px solid #e5e7eb', padding: '14px 24px', display: 'flex', alignItems: 'center', boxShadow: '0 1px 3px rgba(0,0,0,0.05)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ width: 36, height: 36, background: 'linear-gradient(135deg, #3b82f6, #06b6d4)', borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 16, fontWeight: 'bold', boxShadow: '0 2px 4px rgba(59, 130, 246, 0.2)' }}>
AI
</div>
<h1 style={{ fontSize: 18, fontWeight: 600, color: '#1f2937', margin: 0 }}>LangChain AI 聊天</h1>
</div>
</header>
{/* 聊天主体区域 */}
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 16px' }}>
<div style={{ maxWidth: 768, margin: '0 auto', display: 'flex', flexDirection: 'column', gap: 20 }}>
{messages.length === 0 && (
<div style={{ textAlign: 'center', padding: '80px 0', color: '#9ca3af' }}>
<p style={{ fontSize: 18, margin: 0 }}>你好👋,我是AI助手,有什么可以帮你的吗?</p>
</div>
)}
{messages.map((msg, index) => (
<div
key={index}
style={{ display: 'flex', gap: 12, ...(msg.role === 'user' ? { flexDirection: 'row-reverse' } : {}) }}
>
{/* 头像 - 更明显 */}
<div style={{
width: 36,
height: 36,
borderRadius: '50%',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 14,
fontWeight: 'bold',
backgroundColor: msg.role === 'user' ? '#6b7280' : '#3b82f6',
boxShadow: '0 2px 6px rgba(0,0,0,0.15)'
}}>
{msg.role === 'user' ? '我' : '🤖'}
</div>
{/* 消息气泡 - 修复溢出 */}
<div style={{
maxWidth: '80%',
padding: '14px 18px',
borderRadius: 18,
overflow: 'hidden',
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
...(msg.role === 'user'
? { backgroundColor: '#3b82f6', color: '#fff', borderTopRightRadius: 6 }
: { backgroundColor: '#ffffff', color: '#1f2937', borderTopLeftRadius: 6, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' })
}}>
<div
className="markdown-body"
style={{
lineHeight: 1.7,
margin: 0,
}}
dangerouslySetInnerHTML={{ __html: marked.parse(msg.content) }}
/>
</div>
</div>
))}
{/* 加载中动画 */}
{isLoading && messages[messages.length - 1]?.role !== 'assistant' && (
<div style={{ display: 'flex', gap: 12 }}>
<div style={{
width: 36,
height: 36,
borderRadius: '50%',
backgroundColor: '#3b82f6',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 14,
fontWeight: 'bold',
boxShadow: '0 2px 6px rgba(0,0,0,0.15)'
}}>
🤖
</div>
<div style={{ backgroundColor: '#ffffff', padding: '14px 18px', borderRadius: 18, borderTopLeftRadius: 6, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ width: 8, height: 8, backgroundColor: '#9ca3af', borderRadius: '50%', animation: 'bounce 1.4s infinite ease-in-out both', animationDelay: '0ms' }}></div>
<div style={{ width: 8, height: 8, backgroundColor: '#9ca3af', borderRadius: '50%', animation: 'bounce 1.4s infinite ease-in-out both', animationDelay: '0.2s' }}></div>
<div style={{ width: 8, height: 8, backgroundColor: '#9ca3af', borderRadius: '50%', animation: 'bounce 1.4s infinite ease-in-out both', animationDelay: '0.4s' }}></div>
</div>
</div>
</div>
)}
<div ref={chatEndRef} />
</div>
</div>
{/* 底部输入区域 */}
<div style={{ backgroundColor: '#ffffff', borderTop: '1px solid #e5e7eb', padding: '16px 16px 24px' }}>
<div style={{ maxWidth: 768, margin: '0 auto' }}>
<div style={{
display: 'flex',
alignItems: 'flex-end',
gap: 12,
backgroundColor: '#f3f4f6',
borderRadius: 24,
padding: '12px 16px',
border: '1px solid #e5e7eb',
transition: 'border-color 0.2s'
}}>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}}
placeholder="输入你的问题,按回车发送..."
style={{
flex: 1,
resize: 'none',
border: 'none',
outline: 'none',
backgroundColor: 'transparent',
color: '#1f2937',
fontSize: 16,
lineHeight: 1.5,
maxHeight: 120
}}
rows={1}
/>
<button
onClick={sendMessage}
disabled={isLoading || !input.trim()}
style={{
width: 40,
height: 40,
borderRadius: '50%',
border: 'none',
backgroundColor: '#3b82f6',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'background-color 0.2s',
...(isLoading || !input.trim() ? { backgroundColor: '#d1d5db', cursor: 'not-allowed' } : {})
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
</div>
<p style={{ fontSize: 12, color: '#9ca3af', textAlign: 'center', margin: '8px 0 0' }}>AI 生成的内容仅供参考</p>
</div>
</div>
{/* 全局样式:Markdown 渲染样式 + 加载动画 */}
<style>{`
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
/* Markdown 渲染样式 */
.markdown-body pre {
background-color: #1f2937;
color: #f9fafb;
padding: 12px;
border-radius: 8px;
overflow-x: auto;
margin: 8px 0;
white-space: pre;
}
.markdown-body code {
background-color: #f3f4f6;
padding: 2px 4px;
border-radius: 4px;
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 0.9em;
}
.markdown-body pre code {
background-color: transparent;
padding: 0;
}
.markdown-body p {
margin: 8px 0;
}
.markdown-body ul, .markdown-body ol {
padding-left: 20px;
margin: 8px 0;
}
.markdown-body li {
margin: 4px 0;
}
.markdown-body h1, .markdown-body h2, .markdown-body h3 {
margin: 12px 0 8px;
font-weight: 600;
}
`}</style>
</div>
);
}
这就完事了,允许就OK了。
下面是我的文件