实战:使用Ollama + Node搭建本地AI问答应用

前言

在当今技术日新月异的浪潮中,AI正以惊人的速度重塑我们的生活与工作方式,其影响力无孔不入。对于许多开发者来说,AI 常常被一层神秘的面纱所笼罩,似乎高不可攀。但实际情况并非如此!像古语说的那样:"临渊羡鱼,不如退而结网"。与其仅仅站在技术的边缘仰望,不如亲自实践,真正踏入这片充满机遇的领域。

这个项目通过搭建一个基于本地大模型服务的简易AI聊天应用,从零开始摸索,逐步构建自己的全栈开发技能。无论你是渴望深入了解全栈开发的前端小伙伴,还是对最新AI技术感到好奇的萌新,希望这段旅程能够成为我们共同成长的一部分,让每一个热爱技术的朋友都能有所收获。准备好了吗?让我们开始吧!

Ollama简介

Ollama 是一款支持本地部署的大模型工具,可以使用多种主流大模型,它使得开发者可以在自己的电脑上轻松利用先进的 AI 模型进行开发。

我们先从官网上安装Ollama,然后从模型列表中挑选一个,在命令行中启动本地模型:

bash 复制代码
# 下载一个模型
ollama run llama3.2

然后就可以在命令行中进行问答了:

接下来我们的目标是搭建一个Web应用来提供AI问答服务。根据Ollama 官方API,我们可以使用的接口有:

  • /api/generate :用于生成单个文本片段。它接收输入并返回基于该输入的模型生成的文本,适合一次性生成任务。
  • /api/chat:用于支持对话式的交互。它接收一个消息列表作为输入,以维护对话的历史和上下文,使模型能够理解并响应连续的对话。它适合于多轮对话的应用场景。

通过这两个API,我们就可以轻松地实现AI生成和对话的功能了。在本文中,我们将介绍如何基于Ollama构建一个简单的本地在线AI问答应用,接着往下看。

需求分析

我们要实现一个简单的基于Ollama服务的本地在线AI问答应用,它的页面布局如下:

  • 左侧sidebar:显示agent列表,我们提供一个名为"AI生成"的agent。
  • 右侧区域:聊天区,用户可以跟后端Ollama服务进行问答交互,支持实时流式响应。

问答功能

  • 我们先从简单的功能入手,使用Ollama的 /api/generate API接口支持一次性生成任务
  • 问答采用流式响应+一问一答的模式,流式响应可以很好地适配AIGC基于上下文生成后续内容的工作方式,一问一答的串行模式可以降低技术复杂度,也更符合用户线性聊天的交互习惯。
  • 支持停止当前回答的机制,提升用户体验。

大致的效果如下:

技术栈分析

明确需求和功能之后,接下来考虑寻找合适的工具和框架快速搭建我们的应用。

AI问答应用包含客户端、node后端服务、Ollama服务三大部分,它们之间的关系如下:

sequenceDiagram participant 浏览器 participant 后端服务 as Node后端 participant Ollama服务 浏览器->>后端服务: 发起请求 后端服务->>Ollama服务: 调用 /api/generate Ollama服务-->>后端服务: 返回流式响应 后端服务-->>浏览器: 推送至前端

后端服务

  • Node.js:Node.js 的事件驱动架构能够高效处理大量并发连接,其非阻塞I/O模型特别适合实时应用。
  • Koa:轻量级、现代化的Node.js框架,可以快速搭建应用,让我们专注于业务逻辑的实现。
  • Socket.IO:实现实时双向通信,特别适合流式数据传输场景。
  • Undici:高性能HTTP客户端,用于与Ollama服务通信。

前端应用

  • React:主流的前端框架,便于快速搭建用户界面,实现状态管理和实时更新。
  • Tailwind CSS:开箱即用的样式系统,快速实现一个UI设计界面。
  • Socket.IO Client:通过 Socket.IO 与后端进行实时通信,接收流式响应并触发 UI 更新。

功能实现

后端服务

搭建服务

初始化项目,安装依赖:

bash 复制代码
mkdir server
cd server
npm init -y
npm install --save koa socket.io undici

package.json 中添加以下字段以启用 ES Module,并添加start命令:

json 复制代码
{
  "type": "module",
  "scripts": {
    "start": "node app.js"
  }
}

项目架构如下:

go 复制代码
server/
├── app.js          // 主入口文件
├── services/       // 业务逻辑
│   └── ollama.js   // Ollama 请求封装
├── utils/          // 工具函数
│   └── socket.js   // Socket.IO 初始化
└── package.json    // 依赖管理

我们在主入口文件中创建HTTP服务器并监听4000端口,并且将 WebSocket 服务器实例(io)挂载到 HTTP 服务器上,使得服务器能够支持实时双向通信(如流式数据传输、事件监听等)。主入口文件 (app.js)实现如下:

js 复制代码
// app.js
import Koa from "koa";
import { createServer } from "http";
import { Server } from "socket.io";
import { initSocket } from "./utils/socket.js";

const app = new Koa();
const server = createServer(app.callback());
// 监听Socket.IO
const io = new Server(server);
initSocket(io);

server.listen(4000, () => {
  console.log("Server running on http://127.0.0.1:4000");
});

initSocket方法定义了一个用于初始化 Socket.IO 的函数,它封装了与客户端的实时通信逻辑。通过监听 Socket.IO 的核心事件(如连接、断开连接等),实现了对客户端请求的处理和资源清理。

js 复制代码
// utils/socket.js
export function initSocket(io) {
  // Socket.IO 处理
  io.on("connection", (socket) => {
    console.log("Client connected:", socket.id);

    // 监听客户端发送的 request 事件
    socket.on("request", ({ prompt }) => {
      // TODO
    });

    // 监听客户端发送的 stop 事件
    socket.on("stop", () => {
      // TODO
    });

    // 断开连接时清理监听器
    socket.on("disconnect", () => {
      // TODO
      console.log("Client disconnected:", socket.id);
    });
  });
}

它主要处理几个关键事件:

  • 连接建立connection):当客户端成功连接到服务器时触发,每个客户端会分配一个唯一的 socket.id,用于标识连接。
  • 请求事件request):客户端发送消息时触发,在这个事件中调用后端服务生成响应(如流式数据)
  • 中止回答事件stop):客户端发送停止当前回答时触发。在这个事件中取消后端服务响应
  • 连接断开disconnect):当客户端断开连接时触发,确保清理所有与该客户端相关的资源

核心功能实现

request/stop事件处理

我们看socket.js具体的实现:

request事件中,它接收客户端发送的prompt参数,然后用这个参数调用generateResponse方法获取响应。在onChunk回调中通过socket.emit("response", { chunk, done })将流式响应发送给客户端。

generateResponse方法立即返回一个终止响应的回调方法,可用于终止回答。由于我们采用串行问答模式,每个用户最多只有一个正在处理的响应流,可以在connection回调中使用一个abortCallback来记录当前用户的停止回调。

js 复制代码
// Socket.IO 处理
io.on("connection", (socket) => {
  console.log("Client connected:", socket.id);

  // 定义停止函数
  let abortCallback;
  const runAbortCallback = () => {
    if (!abortCallback) {
      return;
    }
    console.log("Stopping current response...");
    try {
      abortCallback(); // 停止当前的流式响应
    } catch (e) {
      console.error("Error stopping response:", e);
    } finally {
      abortCallback = null; // 清理控制器
    }
  };

  const onChunk = (chunk, done) => {
    // 将流式响应发送给对应的客户端
    socket.emit("response", { chunk, done });
    if (done) {
      console.log("response done");
      abortCallback = null;
    }
  };

  const onError = (error) => {
    socket.emit("response", { error, done: true });
    console.log("response error");
  };

  // 监听客户端发送的 request 事件
  socket.on("request", ({ prompt }) => {
    if (!prompt) {
      console.error("Missing prompt");
      return;
    }

    // 如果已经有正在进行的流式响应,则先停止它
    if (abortCallback) {
      console.warn("Previous response is still running, aborting it...");
      runAbortCallback(); // 停止之前的流式响应
    }

    // 调用生成响应函数
    abortCallback = generateResponse(prompt, onChunk, onError);
  });
  // ...
});

然后就是在stop事件触发时调用runAbortCallback从而终止当前响应流,以及在disconnect断开连接时清理资源。以下是socket.js的完整实现:

js 复制代码
// utils/socket.js
import { generateResponse } from "../services/ollama.js";

export function initSocket(io) {
  // Socket.IO 处理
  io.on("connection", (socket) => {
    console.log("Client connected:", socket.id);

    // 定义停止函数
    let abortCallback;
    const runAbortCallback = () => {
      if (!abortCallback) {
        return;
      }
      console.log("Stopping current response...");
      try {
        abortCallback(); // 停止当前的流式响应
      } catch (e) {
        console.error("Error stopping response:", e);
      } finally {
        abortCallback = null; // 清理控制器
      }
    };

    const onChunk = (chunk, done) => {
      // 将流式响应发送给对应的客户端
      socket.emit("response", { chunk, done });
      if (done) {
        console.log("response done");
        abortCallback = null;
      }
    };

    const onError = (error) => {
      socket.emit("response", { error, done: true });
      console.log("response error");
    };

    // 监听客户端发送的 request 事件
    socket.on("request", ({ prompt }) => {
      if (!prompt) {
        console.error("Missing prompt");
        return;
      }

      // 如果已经有正在进行的流式响应,则先停止它
      if (abortCallback) {
        console.warn("Previous response is still running, aborting it...");
        runAbortCallback(); // 停止之前的流式响应
      }

      // 调用生成响应函数
      abortCallback = generateResponse(prompt, onChunk, onError);
    });

    // 绑定 stop 事件监听器
    socket.on("stop", () => {
      runAbortCallback();
    });

    // 断开连接时清理监听器
    socket.on("disconnect", () => {
      console.log("Client disconnected, aborting current response...");
      // 确保断开连接时停止所有流式响应
      runAbortCallback();
      console.log("Client disconnected:", socket.id);
    });
  });
}
请求Ollama服务

然后我们看看Ollama服务方法。 generateResponse方法分两步处理:

  • 通过handleStreamResponse方法异步处理Ollama请求响应
  • 立即返回一个取消请求的回调方法
js 复制代码
// services/ollama.js
import { request } from "undici";

const OLLAMA_URL = "http://127.0.0.1:11434/api/generate";
const MODEL = "llama3.2";

// 处理流式响应的核心逻辑
async function handleStreamResponse(prompt, onChunk, onError, signal) {
  try {
    const requestBody = JSON.stringify({
      model: MODEL,
      prompt,
      stream: true,
    });

    const { statusCode, body } = await request(OLLAMA_URL, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: requestBody,
      signal,
    });

    if (statusCode !== 200) {
      throw new Error(`Ollama 服务返回错误状态码: ${statusCode}`);
    }

    const decoder = new TextDecoder("utf-8");
    for await (const chunk of body) {
      const parsedChunk = decoder.decode(chunk);
      const { response, done } = JSON.parse(parsedChunk);
      onChunk(response, done);
    }
  } catch (error) {
    if (!signal.aborted) {
      console.error("Error generating response:", error.message);
      onError(error);
    }
  }
}

// 主方法:生成响应并返回取消回调
export function generateResponse(prompt, onChunk, onError) {
  const controller = new AbortController();
  const signal = controller.signal;

  // 调用流式响应处理逻辑
  handleStreamResponse(prompt, onChunk, onError, signal);

  // 立即返回取消回调函数
  return () => controller.abort();
}

handleStreamResponse方法中:

  • 通过undicirequest方法请求Ollama服务,请求地址是本地Ollama服务的地址。前面我们已经在本地安装了Ollama工具,并通过ollama serve启动本地服务,它默认运行在本地11434端口。
  • 通过stream: true参数指定流式响应,此时返回的body是一个ReadableStream二进制流,属于异步可迭代对象,可以通过for await ... of 遍历,转成字符串并解析成对象后传给onChunk回调方法。
  • 通过AbortControllersignal可以终止fetch请求。终止请求时会抛出一个AbortError错误,也可以通过signal.aborted来判断。

至此,后端服务的实现就基本完成了。接下来我们要实现客户端的处理。

客户端应用

搭建项目

使用npx create-react-app .快速创建一个React项目,并安装socket.io-client依赖:

bash 复制代码
mkdir client
cd client
npx create-react-app .
npm install --save socket.io-client

为了使用tailwindcss,还要安装依赖并初始化:

bash 复制代码
npm install -D tailwindcss@3 postcss autoprefixer
npx tailwindcss-cli init -p

这将自动生成tailwind.config.jspostcss.config.js 配置文件。

注意,tailwind从v4开始将postcss插件成了@tailwindcss/postcss。我们这里用的是v3版本。

编辑tailwind.config.js配置内容如下:

js 复制代码
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/**/*.{js,jsx,ts,tsx}", // 扫描所有 React 文件
  ],
  theme: {
    extend: {},
  },
  plugins: [],
};

编辑postcss.config.js配置内容如下。

js 复制代码
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}

然后在src/styles/index.css中引入tailwind样式:

css 复制代码
@tailwind base;
@tailwind components;
@tailwind utilities;

项目架构如下:

bash 复制代码
client/
├── src/
│   ├── components/        # React 组件
│   │   ├── ChatArea.jsx   # 聊天区域组件
│   │   ├── Sidebar.jsx    # 侧边栏组件
│   │   └── Home.jsx       # 主页组件
│   ├── utils/             # 工具函数
│   │   └── socket.js      # Socket.IO 封装
│   ├── App.jsx            # 主应用组件
│   ├── index.js           # 应用入口文件
│   └── styles/            # 样式文件
│       └── index.css      # 全局样式(包含 TailwindCSS)
├── package.json           # 项目配置
├── tailwind.config.js     # TailwindCSS 配置
└── postcss.config.js      # PostCSS 配置

应用入口

我们在Home组件中提供一个包含侧边栏和聊天区的页面:

js 复制代码
import Sidebar from './Sidebar';
import ChatArea from './ChatArea';

export default function Home() {
  return (
    <div className="flex h-screen">
      <Sidebar />
      <ChatArea />
    </div>
  );
}

然后在App根组件中直接加载Home组件:

js 复制代码
import Home from './components/Home';

function App() {
  return (
    <div className="h-screen w-screen">
      {/* 渲染 Home 组件 */}
      <Home />
    </div>
  );
}

export default App;

最后在index.js入口文件中加载并渲染App根组件和tailwind全局样式:

js 复制代码
import React from 'react';
import ReactDOM from 'react-dom/client';
import './styles/index.css'; // 引入全局样式(包含 TailwindCSS)
import App from './App';

// 创建 React 应用的根节点
const root = ReactDOM.createRoot(document.getElementById('root'));

// 渲染应用
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

在public目录下提供一个简单的index.html模板:

html 复制代码
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <title>My Agent</title>
    <meta name="keywords" content="AI Agent"> 
    <meta name="description" content="local AI Chat with Ollama"/>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
  </body>
</html>

这样客户端的雏形就确定了,接下来就是在Siderbar和ChatArea组件中确定我们的功能。

核心功能实现

在Siderbar组件中提供一个简单的侧边栏,方便后续添加扩展功能。目前只有AI生成一项:

js 复制代码
export default function Sidebar() {
  return (
    <aside className="w-64 bg-gray-800 text-white p-4">
      <h2 className="text-lg font-bold">Agents</h2>
      <ul>
        <li className="mt-2 cursor-pointer">AI 生成</li>
      </ul>
    </aside>
  );
}

为了跟服务端进行实时问答,我们通过socket.io-client库实现WebSocket通信,在utils/socket.js中管理:

js 复制代码
import { io } from "socket.io-client";

const URL = "http://127.0.0.1:4000"

const socket = io(URL, {
  transports: ["websocket"],
});

export default socket;

AI聊天相关的功能在CharArea组件中提供。

消息发送

第一步实现消息发送功能:

  • 用户在输入框中输入文本后可以点击发送按钮或按下回车键发送消息,然后通过socket.emit触发request事件
  • 使用isStreaming标记是否正在流式响应,在响应过程中用户可以点击停止按钮,此时通过socket.emit触发stop事件。我们先用setTimeout模拟在发送消息5秒后结束流式响应。
js 复制代码
import socket from "../utils/socket"; // 导入 socket 对象

export default function ChatArea() {
  const [currentMessage, setCurrentMessage] = useState(""); // 当前输入的消息
  const [isStreaming, setIsStreaming] = useState(false); // 是否正在流式响应

  // 发送消息
  const sendMessage = () => {
    if (!currentMessage.trim()) return;
    setCurrentMessage(""); // 清空输入框
    // 发送请求
    socket.emit("request", { prompt: currentMessage });
    // 模拟流式响应结束
    setTimeout(() => setIsStreaming(false), 5000);
  };

  // 处理键盘事件:按下回车键发送消息
  const handleKeyDown = (e) => {
    if (e.key === "Enter" && !e.shiftKey) {
      if (e.nativeEvent.isComposing) return; // 忽略中文输入法的组合状态
      if (isStreaming) return; // 如果正在流式响应,直接返回
      e.preventDefault(); // 防止换行
      sendMessage();
    }
  };

  // 停止流式响应
  const stopStream = () => {
    socket.emit("stop");
    setIsStreaming(false);
  };

  return (
    <main className="flex flex-1 flex-col h-screen bg-gray-100">
      {/* 消息显示区 */}
      <div className="flex-1 overflow-y-auto p-4 space-y-2">
      </div>

      {/* 消息发送区 */}
      <div className="p-4 bg-white border-t border-gray-200">
        <div className="flex items-center">
          <input
            type="text"
            value={currentMessage}
            onChange={(e) => setCurrentMessage(e.target.value)}
            onKeyDown={handleKeyDown} // 监听键盘事件
            className="flex-1 p-2 border rounded mr-2"
            placeholder="输入消息..."
          />
          {isStreaming ? (
            <button
              onClick={stopStream}
              className="px-4 py-2 bg-red-500 text-white rounded"
            >
              停止
            </button>
          ) : (
            <button
              onClick={sendMessage}
              disabled={!currentMessage.trim()}
              className="px-4 py-2 bg-blue-500 text-white rounded"
            >
              发送
            </button>
          )}
        </div>
      </div>
    </main>
  );
}
消息状态管理

接下来,就要接收和展示问答信息。

首先需要考虑会话消息流的状态管理。AI问答采用的是流式响应+一问一答的模式,我们基于这个模式来设计。

  • 为每个消息附加一个消息id,使用messageIds数组记录消息序列。
  • 消息内容在messageMap中管理,它包含 [消息id-消息对象]的键值对。消息对象中除了消息主体,还包含发送方、是否完成等信息。
  • 使用streamingId来标记当前流式响应的消息id。通过!!streamingId判断isStreaming状态。

发送消息时调用handleNewRound方法处理新一轮对话,预先生成并插入用户消息和系统消息。

js 复制代码
export default function ChatArea() {
  const [messageIds, setMessageIds] = useState([]); // 消息 ID 列表
  const [messageMap, setMessageMap] = useState({}); // 消息 ID -> 消息对象的映射
  const [streamingId, setStreamingId] = useState(null); // 当前流式响应的消息 ID

  // 是否正在进行流式响应
  const isStreaming = !!streamingId;

  // 新的一轮对话
  const handleNewRound = () => {
    const userMessageId = Date.now(); // 用户消息 ID
    const systemMessageId = userMessageId + 1; // 系统消息 ID

    // 添加用户消息和预留空的系统消息
    setMessageIds((prev) => [...prev, userMessageId, systemMessageId]);
    setMessageMap((prev) => ({
      ...prev,
      [userMessageId]: {
        id: userMessageId,
        content: currentMessage,
        type: "user",
        done: true, // 用户消息默认已完成
      },
      [systemMessageId]: {
        id: systemMessageId,
        content: "",
        type: "system",
        done: false, // 系统消息初始未完成
      },
    }));
    setStreamingId(systemMessageId); // 设置流式响应消息 ID
  };

  // 发送消息
  const sendMessage = () => {
    if (!currentMessage.trim()) return;
    handleNewRound(); // 处理新一轮的对话
    setCurrentMessage(""); // 清空输入框
    // 发送请求
    socket.emit("request", { prompt: currentMessage });
  };

  // 停止流式响应
  const stopStream = () => {
    socket.emit("stop");
    setStreamingId(null); // 停止流式响应
  };

  // ...
}
接收响应

为了接收动态响应,我们在useEffect中监听socketresponse事件:

js 复制代码
// 注册和注销 Socket 监听器
useEffect(() => {
  socket.on("error", (e) => {
    console.log("error", e);
  });
  // 注册监听器
  socket.on("response", handleResponse);

  // 清理监听器
  return () => {
    socket.off("response", handleResponse);
  };
}, [handleResponse]);

response事件的回调方法中,可以接收到服务端返回的参数有响应片段chunk或error,以及done表示是否完成。在这个方法中做了几个处理:

  • 追加流式响应内容:根据streamingId记录的流式响应id来更新messageMap中的消息内容。为了减少依赖,这里使用streamingIdRef来获取streamingId
  • 流式响应结束:当done为true时表示流式响应完成,释放流式响应id。
  • 错误处理:先做简单处理,遇到error直接结束回答。
js 复制代码
// handleResponse 回调函数
const handleResponse = useCallback(({ chunk, done, error }) => {
  if (error) {
    console.error("handleResponse Error:", error);
    updateStreamingId(null); // 清空流式响应消息
    return;
  }

  console.log("handleResponse", chunk, done);
  const currentStreamingId = streamingIdRef.current; // 获取当前的 streamingId
  if (!currentStreamingId) {
    return;
  }

  if (chunk) {
    // 更新流式响应内容
    setMessageMap((prev) => ({
      ...prev,
      [currentStreamingId]: {
        ...prev[currentStreamingId],
        content: (prev[currentStreamingId]?.content || "") + chunk, // 追加流式内容
        done   // 更新完成状态
      },
    }));
  }

  if (done) {
    updateStreamingId(null); // 清空流式响应消息
  }
}, []);
展示会话消息

我们通过messageIds消息序列来渲染消息列表:

js 复制代码
return (
  <main className="flex flex-1 flex-col h-screen bg-gray-100">
    {/* 消息显示区 */}
    <div
      ref={messagesDOMRef}
      className="flex-1 overflow-y-auto p-4 space-y-2"
    >
      {messageIds.map((id) => (
        <MessageItem key={id} {...messageMap[id]} />
      ))}
    </div>

    {/* 消息发送区 */}
    {/* ... */}
  </main>
);

消息内容在MessageItem中渲染。根据type来渲染用户消息或系统消息的内容。

js 复制代码
function MessageItem({ type, content }) {
  return (
    <div
      className={`p-2 rounded break-words min-h-[2em] ${
        type === "user"
          ? "bg-blue-100 text-blue-800 self-end"
          : "bg-gray-200 text-gray-800 self-start"
      }`}
    >
      {content || <Spinner />}
    </div>
  );
}

流式响应消息为空时可以显示一个简单的loading效果:

js 复制代码
function Spinner() {
  return (
    <div className="inline-block h-4 w-4 animate-spin rounded-full border-2 opacity-50 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]"></div>
  );
}

这样就基本实现了会话消息列表的展示了。

还可以加上简单的优化:发送消息后滚动到消息列表的底部:

js 复制代码
// 滚动到底部
const scrollToBottom = () => {
  if (messagesDOMRef.current) {
    messagesDOMRef.current.scrollTop = messagesDOMRef.current.scrollHeight;
  }
};

// 消息更新时自动滚动到底部
useEffect(() => {
  scrollToBottom();
}, [messageIds]); // 依赖 messageIds 变化触发滚动

ChatArea组件的完整代码如下:

js 复制代码
import { useState, useCallback, useEffect, useRef } from "react";
import socket from "../utils/socket"; // 导入 socket 对象

export default function ChatArea() {
  const [messageIds, setMessageIds] = useState([]); // 消息 ID 列表
  const [messageMap, setMessageMap] = useState({}); // 消息 ID -> 消息对象的映射
  const [currentMessage, setCurrentMessage] = useState(""); // 当前输入的消息
  const [streamingId, setStreamingId] = useState(null); // 当前流式响应的消息 ID
  const streamingIdRef = useRef(null); // 使用 useRef 存储 streamingId
  const messagesDOMRef = useRef(null); // 获取消息区域的引用

  // 是否正在进行流式响应
  const isStreaming = !!streamingId;

  const updateStreamingId = (streamingId = null) => {
    streamingIdRef.current = streamingId; // 更新 streamingIdRef
    setStreamingId(streamingId); // 设置流式响应消息 ID
  };

  // handleResponse 回调函数
  const handleResponse = useCallback(({ chunk, done, error }) => {
    if (error) {
      console.error("handleResponse Error:", error);
      updateStreamingId(null); // 清空流式响应消息
      return;
    }

    console.log("handleResponse", chunk, done);
    const currentStreamingId = streamingIdRef.current; // 获取当前的 streamingId
    if (!currentStreamingId) {
      return;
    }

    if (chunk) {
      // 更新流式响应内容
      setMessageMap((prev) => ({
        ...prev,
        [currentStreamingId]: {
          ...prev[currentStreamingId],
          content: (prev[currentStreamingId]?.content || "") + chunk, // 追加流式内容
          done   // 更新完成状态
        },
      }));
    }

    if (done) {
      updateStreamingId(null); // 清空流式响应消息
    }
  }, []);

  // 注册和注销 Socket 监听器
  useEffect(() => {
    socket.on("error", (e) => {
      console.log("error", e);
    });
    // 注册监听器
    socket.on("response", handleResponse);

    // 清理监听器
    return () => {
      socket.off("response", handleResponse);
    };
  }, [handleResponse]);

  // 新的一轮对话
  const handleNewRound = () => {
    const userMessageId = Date.now(); // 用户消息 ID
    const systemMessageId = userMessageId + 1; // 系统消息 ID

    // 添加用户消息和预留空的系统消息
    setMessageIds((prev) => [...prev, userMessageId, systemMessageId]);
    setMessageMap((prev) => ({
      ...prev,
      [userMessageId]: {
        id: userMessageId,
        content: currentMessage,
        type: "user",
        done: true, // 用户消息默认已完成
      },
      [systemMessageId]: {
        id: systemMessageId,
        content: "",
        type: "system",
        done: false, // 系统消息初始未完成
      },
    }));
    updateStreamingId(systemMessageId); // 设置流式响应消息 ID
  };

  // 发送消息
  const sendMessage = () => {
    if (!currentMessage.trim()) return;
    handleNewRound(); // 处理新一轮的对话
    setCurrentMessage(""); // 清空输入框
    // 发送请求
    socket.emit("request", { prompt: currentMessage });
  };

  // 处理键盘事件:按下回车键发送消息
  const handleKeyDown = (e) => {
    if (e.key === "Enter" && !e.shiftKey) {
      if (e.nativeEvent.isComposing) return; // 忽略中文输入法的组合状态
      if (isStreaming) return; // 如果正在流式响应,直接返回
      e.preventDefault(); // 防止换行
      sendMessage();
    }
  };

  // 停止流式响应
  const stopStream = () => {
    socket.emit("stop");
    updateStreamingId(null); // 停止流式响应
  };

  // 滚动到底部
  const scrollToBottom = () => {
    if (messagesDOMRef.current) {
      messagesDOMRef.current.scrollTop = messagesDOMRef.current.scrollHeight;
    }
  };

  // 消息更新时自动滚动到底部
  useEffect(() => {
    scrollToBottom();
  }, [messageIds]);

  return (
    <main className="flex flex-1 flex-col h-screen bg-gray-100">
      {/* 消息显示区 */}
      <div
        ref={messagesDOMRef}
        className="flex-1 overflow-y-auto p-4 space-y-2"
      >
        {messageIds.map((id) => (
          <MessageItem key={id} {...messageMap[id]} />
        ))}
      </div>

      {/* 消息发送区 */}
      <div className="p-4 bg-white border-t border-gray-200">
        <div className="flex items-center">
          <input
            type="text"
            value={currentMessage}
            onChange={(e) => setCurrentMessage(e.target.value)}
            onKeyDown={handleKeyDown} // 监听键盘事件
            className="flex-1 p-2 border rounded mr-2"
            placeholder="输入消息..."
          />
          {isStreaming ? (
            <button
              onClick={stopStream}
              className="px-4 py-2 bg-red-500 text-white rounded"
            >
              停止
            </button>
          ) : (
            <button
              onClick={sendMessage}
              disabled={!currentMessage.trim()}
              className="px-4 py-2 bg-blue-500 text-white rounded"
            >
              发送
            </button>
          )}
        </div>
      </div>
    </main>
  );
}

function Spinner() {
  return (
    <div className="inline-block h-4 w-4 animate-spin rounded-full border-2 opacity-50 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]"></div>
  );
}

function MessageItem({ type, content }) {
  return (
    <div
      className={`p-2 rounded break-words min-h-[2em] ${
        type === "user"
          ? "bg-blue-100 text-blue-800 self-end"
          : "bg-gray-200 text-gray-800 self-start"
      }`}
    >
      {content || <Spinner />}
    </div>
  );
}

运行项目

  1. 首先启动Ollama服务,它默认运行在本地11434端口
bash 复制代码
ollama serve
  1. /server目录下启动本地服务端,它将监听本地4000端口
bash 复制代码
npm start
  1. /client目录下运行本地客户端,它默认运行在3000端口中
bash 复制代码
npm start

在浏览器中访问http://localhost:3000/就可以看到我们的页面了。

但是由于客户端和服务端使用了不同的端口号,Socket.IO连接时会遇到跨域问题。不过Socket.IO已经提供了CORS处理机制,我们只需要在server/app.js中添加cors配置即可:

js 复制代码
// 监听Socket.IO
const io = new Server(server, {
  cors: {
    origin: "http://127.0.0.1:3000", // 允许的前端地址
    methods: "*", // 允许的 HTTP 方法
  },
});

Socket.IO 的 CORS 配置实际上是基于 Node.js 的 cors 库实现的。配置了 cors 选项之后,它会自动将这些配置应用到底层的 HTTP 服务器上(比如 Access-Control-Allow-Origin)。

重启服务器,就可以在页面上进行问答了:

在Ollama服务中可以看到请求状态:

至此,我们的本地AI问答项目就成功跑起来啦!

结语

本文基于Ollama + Node做了一个简单的本地AI问答应用。当我们的本地AI问答项目跑起来的那一刻,或许你会感叹:其实技术落地并没有那么遥不可及!

如今我们有各种丰富的社区资源,也有强大的AI智能伙伴,从零开始去做落地一个项目并没有没那么难,但是实际做的过程也不总是一帆风顺。技术的真谛不只是最终的成果,而是在实践过程中沉淀的思考与成长。

作为demo项目,它还有很多值得改进的地方,这些不足之处留给我们的是进一步思考、探索的空间。在这里分享记录,希望可以抛砖引玉,如果你有任何疑问或想法,欢迎留言,一起讨论、共同进步🚀🚀

相关推荐
kyriewen10 分钟前
豆包和千问同时关了智能体,我用它们搭的 3 个自动化全废了——迁移方案整理
前端·javascript·ai编程
前端一小卒23 分钟前
我用 TypeScript 从零手写了一个 Claude Code,然后发现它的核心只有 30 行
前端·agent
大圣编程2 小时前
Python中continue语句的用法是什么?
开发语言·前端·python
yuhaiqiang2 小时前
随手 vibecoding 的浏览器插件已经 6000 多次下载,聊聊他的产品设计
前端·后端·面试
之歆3 小时前
Vue商品详情与放大镜组件
前端·javascript·vue.js
再吃一根胡萝卜3 小时前
如何把小米 MiMo 接入 CodeBuddy,打造私有 Agent
前端
负责的蛋挞4 小时前
异步HttpModule的实现方式
java·服务器·前端
丹宇码农7 小时前
把 HLS 字幕玩出花:zwPlayer 如何让 M3U8 视频支持全文搜索、翻译与码率自适应
前端·javascript·音视频·hls·视频播放器
2501_943782357 小时前
【共创季稿事节】猜数字游戏:二分法思维与交互式反馈
前端·游戏·microsoft·harmonyos·鸿蒙·鸿蒙系统