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. 启动ollama,ollama services start ollama
  3. 拉取mistral模型:ollama pull mistral
  4. 运行mistral模型:ollama run mistral
  • OK了,然后到这里你就可以在终端或者使用js脚本去访问这个服务并获取内容了,下面我给出js案例

Nodejs+Express搭建简易后端

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

封装与大模型通信的方法

  • 这段代码用 LangChain 的ChatOllama把本地 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>
  );
}
相关推荐
IT_陈寒14 分钟前
SpringBoot自动配置差点让我加班到凌晨
前端·人工智能·后端
yumgpkpm28 分钟前
Acceldata ODP(Open Data Platform)3.3.6.4(RHEL9)保姆级完整安装手册
大数据·人工智能·hive·hadoop·kafka·hbase·cloudera
程序员cxuan30 分钟前
腾讯又来一王炸,开源版 WorkBuddy 太夯了!
人工智能·后端·程序员
邓工说电40 分钟前
智慧断路器安全吗?数据加密、离线保护与合规认证全解读
大数据·数据库·人工智能·智能断路器·炜晔科技
阿里云大数据AI技术42 分钟前
Lance 数据检索怎么选,当然阿里云 Milvus 向量湖
人工智能
出海客44 分钟前
跨境电商多语言客服知识库怎么建:资料结构、检索边界与人工升级
大数据·人工智能
xsd202411181 小时前
从自主导航到视觉读表:一台工业巡检机器人的全栈技术链路拆解
人工智能
袁哥大话安全1 小时前
巡隐WEBSHELL扫描软件
人工智能·安全·web
论文复现现场1 小时前
8卡4090能跑70B吗?Llama-2显存预算、QLoRA与通信瓶颈
人工智能·深度学习·分布式训练·llama·显存·qlora·算家云
酷虎软件1 小时前
如何用AI创作AI歌曲AI音乐
人工智能