langchain+ollama+Next.js实现AI对话聊天框

本地部署ollama

效果展示

  • 这里我并没有设置大模型的流式输出,而是采用的前端策略----打字机效果 ,不过一般都是采用流式输出+SSE 来实现这种效果的

关于ollama

  • Ollama 就是"本地大模型 Docker"------一键拉模型、秒级启动、离线可聊、OpenAI 兼容,8G 显存就能跑 7B,CLI/API 即开即用,零门槛拥有完全本地、免费、可编程的 AI 服务。
  • 这里我使用的大模型是mistral,它的优点就是参数少、推理快、提供了GPT-4级与代码能力,适合本地部署用来学习练习使用。

本地部署大模型

  • 这里有Ollama,所以还是比较简单,只需要按照命令执行下去就OK啦
  1. 先安装ollama,波煮这里用的是mac,所有就写mac使用homebrew的指令了brew install ollama
  2. 启动ollamaollama services start ollama
  3. 拉取mistral模型:ollama pull mistral
  4. 运行mistral模型:ollama run mistral
  • OK了,然后到这里你就可以在终端或者使用js脚本去访问这个服务并获取内容了,下面我给出js案例

Nodejs+Express搭建简易后端

  • 这里我们主要需要做两件事:
    1. 搭建一个后端并暴露给前端一个接口(与AI对话的接口)
    2. 在后端封装一个方法去和大模型交流并拿到大模型的响应结果

封装与大模型通信的方法

  • 这段代码用 LangChainChatOllama把本地 Mistral 模型包装成异步函数:接收一段提示词,实时调用并返回纯字符串,方便前端直接显示------相当于一个"即插即用"的本地 AI 问答接口。
  • 这里用到了langchain/ollama,所以需要先npm install一下
  • 其中baseUrl就是你大模型所在的IP与端口,一般就是本地IP加上11434端口
js 复制代码
const { ChatOllama } = require("@langchain/ollama");

const model = new ChatOllama({
  baseUrl: "http://127.0.0.1:11434",
  model: "mistral",
  temperature: 0.9,
  streaming: true,
});

// 传入prompt,获取响应
async function getResponse(prompt) {
  const response = await model.invoke(prompt);

  // 规范化为字符串,方便前端直接显示
  let replyText = '';
  if (typeof response === 'string') {
    replyText = response;
  } else if (response && typeof response === 'object') {
    // 常见字段优先级
    replyText = response.reply ?? response.text ?? response.content ?? response.output ?? JSON.stringify(response);
  } else {
    replyText = String(response);
  }

  return replyText;
}

module.exports = { getResponse };

后端

  • 这里使用Express框架,注意还需要配置一下cors防止前端因为跨域问题无法请求
  • 下面这段代码启动了一个 Express 服务,提供跨域的 /api/chat 接口:接收前端发来的消息,调用本地 Mistral 模型获取回复,并把结果以 JSON 形式返回,从而实现"前端提问 → 后端调用本地 AI → 前端拿到回答"的完整对话链路。
js 复制代码
const express = require('express')
const cors = require('cors')
const { getResponse } = require('./ollama.js')

const app = express()
app.use(cors())
app.use(express.json())



app.get('/', (req, res) => {
  res.send('hello world')
})

app.post('/api/chat', (req, res) => {
  console.log('POST /api/chat body:', req.body);
  if (!req.body || typeof req.body.message !== 'string') {
    return res.status(400).json({ error: 'Invalid request: expected JSON body with a "message" string field.' });
  }

    const prompt = req.body.message;
    getResponse(prompt).then(responseText => {
      // 始终返回一个 JSON,包含 reply 字段,便于前端解析显示
      res.json({ reply: responseText });
    }).catch(err => {
      console.error('getResponse error:', err);
      res.status(500).json({ error: (err && err.message) ? err.message : String(err) });
  });
})




app.listen(10000, () => {
  console.log('服务已启动...');
})

前端

  • 波煮这次用的是nextjs,下面给出核心代码
  1. 维护消息列表与输入框状态,按 Enter 或点击按钮触发发送;
  2. 向后端 /api/chat 发起 POST,带打字机效果逐字展示 AI 回复;
  3. 网络异常时自动降级为本地模拟回复,始终保证交互可用。

核心处理代码

  • 这里的核心其实就是sendMessage这个函数,下面我详细说说这个函数

输入保护+并发锁

js 复制代码
const text = input.trim();
if (!text || isSending) return;
setIsSending(true);
  • 空输入直接 return,避免发空白消息。
  • isSending 是并发锁------防止用户狂点按钮或连续回车导致多个请求同时飞出去(后端可能返回顺序错乱)。

封装用户信息方便与后端交换

js 复制代码
const userMsg: Message = { id: String(Date.now()), role: "user", text };
setMessages(m => [...m, userMsg]);
setInput("");    
  • 先 UI 后请求------让用户感知"秒发",不用等网络返回。
  • Date.now() 当 id 足够,同一毫秒内连续点击概率极低。

真正发请求

js 复制代码
(async () => {
  const res = await fetch("http://localhost:10000/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json", "Accept": "application/json" },
    body: JSON.stringify({ message: text }),
  });
  • 用 立即执行函数包裹 async 逻辑,避免把外层函数变成 async(事件处理器返回 Promise 无意义)。
  • 显式带 Accept: application/json ------某些后端框架靠它路由,防止返回 text/plain 导致下步 JSON 解析崩。

打字机效果

js 复制代码
const id = String(Date.now() + Math.random());
setMessages(m => [...m, { id, role: "ai", text: "" }]);

for (let i = 1; i <= replyText.length; i++) {
  await new Promise(r => setTimeout(r, 8 + Math.random() * 20));
  setMessages(prev =>
    prev.map(msg => (msg.id === id ? { ...msg, text: replyText.slice(0, i) } : msg))
  );
}
  • 随机延迟 8 + Math.random() * 20 毫秒 → 更像人类打字节奏(不是匀速机器),提高用户体验
  • 每条 AI 消息用 时间戳 + 随机数 当 id,同一毫秒内多条消息也不会 key 冲突。
ts 复制代码
"use client";

import { useEffect, useRef, useState } from "react";
import styles from "./chatStyles.module.css";

type Message = {
  id: string;
  role: "user" | "ai";
  text: string;
};

export default function Home() {
  const [messages, setMessages] = useState<Message[]>([
    { id: "1", role: "ai", text: "你好!我是 AI 助手,有什么我可以帮你的?" },
  ]);
  const [input, setInput] = useState("");
  const [isSending, setIsSending] = useState(false);
  const listRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    // 自动滚动到底部
    if (listRef.current) {
      listRef.current.scrollTop = listRef.current.scrollHeight;
    }
  }, [messages]);

  function sendMessage() {
    const text = input.trim();
    if (!text || isSending) return;
    setIsSending(true);

    const userMsg: Message = {
      id: String(Date.now()),
      role: "user",
      text,
    };

    // 调用接口(带 Content-Type,使用 async/await 并以打字机效果展示回复)
    setMessages((m) => [...m, userMsg]);
    setInput("");

    (async () => {
      try {
        const res = await fetch("http://localhost:10000/api/chat", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "Accept": "application/json",
          },
          body: JSON.stringify({ message: text }),
        });

        if (!res.ok) {
          console.error("Server returned status", res.status);
          // 回退到本地模拟回复
          const fallback = `收到:${text} ------ 这是一个模拟回复。`;
          await (async function typewriterFallback() {
            const id = String(Date.now() + Math.random());
            setMessages((m) => [...m, { id, role: "ai", text: "" }] as Message[]);
            for (let i = 1; i <= fallback.length; i++) {
              await new Promise((r) => setTimeout(r, 8 + Math.random() * 20));
              setMessages((prev) => prev.map((msg) => (msg.id === id ? { ...msg, text: fallback.slice(0, i) } : msg)));
            }
            setIsSending(false);
          })();
        } else {
          // 先读取为文本,再尝试解析为 JSON
          const respText = await res.text();
          let replyText = "";
          try {
            const parsed = JSON.parse(respText);
            if (parsed && typeof parsed === "object") {
              // 常见字段检测
              replyText = parsed.reply ?? parsed.text ?? JSON.stringify(parsed);
            } else {
              replyText = String(parsed);
            }
          } catch (e) {
            // 不是 JSON,直接使用原始文本
            replyText = respText;
          }

          // 打字机效果展示 replyText
          const id = String(Date.now() + Math.random());
          setMessages((m) => [...m, { id, role: "ai", text: "" }] as Message[]);
          for (let i = 1; i <= replyText.length; i++) {
            await new Promise((r) => setTimeout(r, 8 + Math.random() * 20));
            setMessages((prev) => prev.map((msg) => (msg.id === id ? { ...msg, text: replyText.slice(0, i) } : msg)));
          }
          setIsSending(false);
        }
      } catch (err) {
        console.error("fetch error:", err);
        // 网络或其它错误时回退到本地模拟回复
        const fallback = `收到:${text} ------ 这是一个模拟回复。`;
        const id = String(Date.now() + Math.random());
        setMessages((m) => [...m, { id, role: "ai", text: "" }] as Message[]);
        for (let i = 1; i <= fallback.length; i++) {
          await new Promise((r) => setTimeout(r, 8 + Math.random() * 20));
          setMessages((prev) => prev.map((msg) => (msg.id === id ? { ...msg, text: fallback.slice(0, i) } : msg)));
        }
        setIsSending(false);
      }
    })();
  }

  function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      sendMessage();
    }
  }

  return (
    <div className={styles.pageWrapper}>
      <div className={styles.container}>
        {/* Header */}
        <div className={styles.header}>
          <div className={styles.headerLeft}>
            <div className={styles.avatar}>AI</div>
            <div>
              <div className={styles.title}>AI 聊天</div>
              <div className={styles.subtitle}>AI对话 --- 回车发送</div>
            </div>
          </div>
        </div>

        {/* Messages */}
        <div ref={listRef} className={styles.messages}>
          {messages.map((m) => (
            <div key={m.id} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}>
              <div className={`${m.role === "user" ? styles.userBubble : styles.aiBubble}`}>
                <div className="whitespace-pre-wrap">{m.text}</div>
              </div>
            </div>
          ))}
        </div>

        {/* Input */}
        <div className={styles.inputWrap}>
          <div className={styles.inputRow}>
            <input
              aria-label="消息输入框"
              value={input}
              onChange={(e) => setInput(e.target.value)}
              onKeyDown={onKeyDown}
              placeholder="输入消息,按 Enter 发送..."
              className={styles.input}
            />

            <button
              onClick={sendMessage}
              disabled={isSending || !input.trim()}
              className={styles.sendButton}
            >
              {isSending ? "发送中..." : "发送"}
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}
相关推荐
西西o1 小时前
面向Agentic Coding的未来:豆包Doubao-Seed-Code模型深度测评与实战
人工智能
行者常至为者常成1 小时前
基于LangGraph的自我改进智能体:Reflection与Reflexion技术详解与实现
人工智能
Yanni4Night2 小时前
JS 引擎赛道中的 Rust 角色
前端·javascript
菠菠萝宝2 小时前
【Java手搓RAGFlow】-9- RAG对话实现
java·开发语言·人工智能·llm·jenkins·openai
大佬,救命!!!2 小时前
最新的python3.14版本下仿真环境配置深度学习机器学习相关
开发语言·人工智能·python·深度学习·机器学习·学习笔记·环境配置
工业机器视觉设计和实现2 小时前
用caffe做个人脸识别
人工智能·深度学习·caffe
paperxie_xiexuo3 小时前
从研究问题到分析初稿:深度解析PaperXie AI科研工具中数据分析模块在学术写作场景下的辅助逻辑与技术实现路径
人工智能·数据挖掘·数据分析
一水鉴天3 小时前
整体设计 定稿 之9 拼语言工具设计之前 的 备忘录仪表盘(CodeBuddy)
人工智能·架构·公共逻辑
IT_陈寒3 小时前
Python性能提升50%:这5个隐藏技巧让你的代码快如闪电⚡
前端·人工智能·后端