写个小dome,sse流式输出调用千问模型(next+langchain)

0 阅读4分钟

先看效果吧

image.png 实现过程: 1.创建项目

npx create-next-app@latest my-ai-app
cd my-ai-app
  1. 安装依赖
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了。 下面是我的文件 image.png