引言

在当今 AI 技术快速发展的时代,聊天机器人已经成为人机交互的重要方式。然而,大多数聊天机器人依赖云端服务,存在数据隐私和网络依赖的问题。本地聊天机器人则可以很好地解决这些问题,同时还能提供更低的延迟和更高的响应速度。

本文将介绍如何使用 LangChain.js、Ollama 和 Vue3 构建一个完整的本地聊天机器人。我们将实现从环境搭建到前后端开发的全过程,最终打造一个功能完善、界面友好的本地聊天应用。

技术栈概述

  • LangChain.js:用于构建基于语言模型的应用程序框架,提供了丰富的工具和接口来简化 LLM 应用开发
  • Ollama:用于在本地运行开源大型语言模型的工具,支持 Llama 2、Mistral 等多种模型
  • Vue3:用于构建用户界面的前端框架,采用组件化开发模式,提供了响应式数据绑定和组合式 API

环境搭建

安装 Ollama

  1. 访问Ollama 官方网站下载并安装 Ollama
  2. 打开终端,执行以下命令拉取并运行模型:

bash

ollama pull llama3
ollama run llama3

  1. 验证 Ollama 服务是否正常运行:

bash

curl http://localhost:11434/api/chat -d '{"model":"llama3","messages":[{"role":"user","content":"Hello"}]}'

配置 CORS

为了解决前端跨域问题,需要配置 Ollama 的 CORS 设置:

Windows 系统

  1. 打开 "控制面板" -> "系统" -> "高级系统设置" -> "环境变量"
  2. 创建新的系统环境变量:
    • 变量名:OLLAMA_ORIGINS
    • 变量值:http://localhost:8080(Vue 开发服务器地址)
  3. 重启 Ollama 服务

macOS/Linux 系统

bash

# 临时设置(当前终端会话有效)
export OLLAMA_ORIGINS="http://localhost:8080"
ollama serve

# 永久设置(根据shell类型选择)
# Bash
echo 'export OLLAMA_ORIGINS="http://localhost:8080"' >> ~/.bashrc
source ~/.bashrc

# Zsh
echo 'export OLLAMA_ORIGINS="http://localhost:8080"' >> ~/.zshrc
source ~/.zshrc

创建 Vue3 项目

bash

# 安装Vue CLI(如果尚未安装)
npm install -g @vue/cli

# 创建Vue3项目
vue create ollama-chatbot
cd ollama-chatbot

# 安装必要依赖
npm install axios pinia @langchain/community @langchain/core ollama

安装 LangChain.js

bash

# 安装LangChain核心包
npm install @langchain/core @langchain/community

# 安装Ollama集成包
npm install ollama

后端实现

创建 Express 服务器

  1. 创建后端目录并初始化:

bash

mkdir backend
cd backend
npm init -y
npm install express cors @langchain/community @langchain/core ollama

  1. 创建服务器文件server.js

javascript

const express = require('express');
const cors = require('cors');
const { ChatOllama } = require('@langchain/community/chat_models/ollama');
const { BufferMemory } = require('langchain/memory');
const { ConversationChain } = require('langchain/chains');

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

// 初始化Ollama模型
const model = new ChatOllama({
  baseUrl: "http://localhost:11434",
  model: "llama3",
  temperature: 0.7,
});

// 创建对话链和记忆存储
const memory = new BufferMemory({ returnMessages: true });
const chain = new ConversationChain({ llm: model, memory });

// 对话API端点
app.post('/api/chat', async (req, res) => {
  try {
    const { message } = req.body;
    
    // 使用对话链处理消息
    const response = await chain.invoke({ input: message });
    
    res.json({
      response: response.response
    });
  } catch (error) {
    console.error('Error:', error);
    res.status(500).json({ error: 'An error occurred while processing your request' });
  }
});

// 流式对话API端点
app.post('/api/chat/stream', async (req, res) => {
  try {
    const { message } = req.body;
    
    // 设置响应头以支持流式传输
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');
    
    // 获取对话历史
    const chatHistory = await memory.loadMemoryVariables({});
    
    // 直接调用Ollama API实现流式响应
    const stream = await model.stream([
      ...chatHistory.chat_history,
      { role: 'user', content: message }
    ]);
    
    // 处理流式响应
    for await (const chunk of stream) {
      res.write(`data: ${JSON.stringify({ chunk: chunk.content })}\n\n`);
      
      // 更新对话记忆
      await memory.saveContext(
        { input: message },
        { output: chunk.content }
      );
    }
    
    // 结束流式响应
    res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
    res.end();
  } catch (error) {
    console.error('Stream error:', error);
    res.status(500).write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
    res.end();
  }
});

// 启动服务器
const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

前端实现

创建 Pinia 状态管理

创建src/store/chatStore.js

javascript

import { defineStore } from 'pinia';
import axios from 'axios';

export const useChatStore = defineStore('chat', {
  state: () => ({
    messages: [],
    isLoading: false,
    error: null,
    model: 'llama3'
  }),
  
  actions: {
    // 发送消息(非流式)
    async sendMessage(message) {
      this.isLoading = true;
      this.error = null;
      
      try {
        // 添加用户消息
        this.messages.push({
          role: 'user',
          content: message
        });
        
        // 调用后端API
        const response = await axios.post('http://localhost:3000/api/chat', {
          message,
          model: this.model
        });
        
        // 添加AI响应
        this.messages.push({
          role: 'assistant',
          content: response.data.response
        });
      } catch (error) {
        console.error('Error sending message:', error);
        this.error = error.message || 'Failed to send message';
      } finally {
        this.isLoading = false;
      }
    },
    
    // 发送消息(流式)
    async sendMessageStream(message) {
      this.isLoading = true;
      this.error = null;
      
      try {
        // 添加用户消息
        this.messages.push({
          role: 'user',
          content: message
        });
        
        // 创建AI消息占位符
        const aiMessageIndex = this.messages.push({
          role: 'assistant',
          content: ''
        }) - 1;
        
        // 使用Fetch API处理流式响应
        const response = await fetch('http://localhost:3000/api/chat/stream', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({
            message,
            model: this.model
          })
        });
        
        if (!response.body) {
          throw new Error('No stream response');
        }
        
        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, { stream: true });
          const lines = chunk.split('\n\n');
          
          for (const line of lines) {
            if (line.startsWith('data:')) {
              const data = line.slice(5).trim();
              if (data) {
                try {
                  const json = JSON.parse(data);
                  
                  if (json.chunk) {
                    // 更新AI消息内容
                    this.messages[aiMessageIndex].content += json.chunk;
                  }
                } catch (e) {
                  console.error('Error parsing stream data:', e);
                }
              }
            }
          }
        }
      } catch (error) {
        console.error('Stream error:', error);
        this.error = error.message || 'Failed to send message';
      } finally {
        this.isLoading = false;
      }
    },
    
    // 清除对话历史
    clearMessages() {
      this.messages = [];
    },
    
    // 切换模型
    setModel(model) {
      this.model = model;
      this.clearMessages(); // 切换模型时清除历史对话
    }
  }
});

创建聊天界面组件

创建src/components/ChatInterface.vue

vue

<template>
  <div class="chat-container">
    <div class="chat-header">
      <h1>本地AI聊天机器人</h1>
      <div class="model-selector">
        <label for="model">选择模型:</label>
        <select 
          id="model" 
          v-model="model" 
          @change="changeModel"
          :disabled="isLoading"
        >
          <option value="llama3">Llama 3</option>
          <option value="mistral">Mistral</option>
          <option value="gemma">Gemma</option>
        </select>
      </div>
    </div>
    
    <div class="chat-messages">
      <div class="message" v-for="(msg, index) in messages" :key="index">
        <div class="message-role" :class="msg.role">
          {{ msg.role === 'user' ? '你' : 'AI' }}
        </div>
        <div class="message-content" v-html="formatMessage(msg.content)"></div>
      </div>
      
      <div class="loading-indicator" v-if="isLoading">
        <div class="dot-pulse"></div>
      </div>
      
      <div class="error-message" v-if="error">
        ⚠️ {{ error }}
      </div>
    </div>
    
    <div class="chat-input">
      <textarea
        v-model="message"
        placeholder="输入你的消息..."
        @keyup.enter.exact="sendMessage"
        :disabled="isLoading"
      ></textarea>
      <button 
        @click="sendMessage" 
        :disabled="!message.trim() || isLoading"
      >
        {{ isLoading ? '发送中...' : '发送' }}
      </button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import { useChatStore } from '@/store/chatStore';
import { marked } from 'marked';
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';

// 初始化marked
marked.setOptions({
  highlight: function(code, lang) {
    if (lang && hljs.getLanguage(lang)) {
      return hljs.highlight(code, { language: lang }).value;
    }
    return hljs.highlightAuto(code).value;
  }
});

// 获取状态管理
const chatStore = useChatStore();
const message = ref('');

// 计算属性
const messages = computed(() => chatStore.messages);
const isLoading = computed(() => chatStore.isLoading);
const error = computed(() => chatStore.error);
const model = computed({
  get: () => chatStore.model,
  set: (value) => chatStore.model = value
});

// 格式化消息(支持Markdown)
const formatMessage = (content) => {
  return marked.parse(content);
};

// 发送消息
const sendMessage = () => {
  if (!message.value.trim()) return;
  
  // 使用流式发送消息
  chatStore.sendMessageStream(message.value);
  
  // 清空输入框
  message.value = '';
};

// 切换模型
const changeModel = () => {
  chatStore.setModel(model.value);
};
</script>

<style scoped>
/* 聊天容器样式 */
.chat-container {
  display: flex;
  flex-direction: column;
  height: 100vh;
  max-width: 1000px;
  margin: 0 auto;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  overflow: hidden;
}

/* 聊天头部样式 */
.chat-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem;
  background-color: #f5f5f5;
  border-bottom: 1px solid #e0e0e0;
}

.model-selector {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.model-selector select {
  padding: 0.5rem;
  border-radius: 4px;
  border: 1px solid #ccc;
}

/* 消息区域样式 */
.chat-messages {
  flex: 1;
  padding: 1rem;
  overflow-y: auto;
  background-color: #fafafa;
}

.message {
  margin-bottom: 1rem;
  display: flex;
  gap: 0.5rem;
}

.message-role {
  font-weight: bold;
  min-width: 60px;
  text-align: right;
  padding-top: 0.5rem;
}

.message-role.user {
  color: #2196f3;
}

.message-role.assistant {
  color: #4caf50;
}

.message-content {
  flex: 1;
  padding: 0.8rem;
  background-color: white;
  border-radius: 8px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

/* 加载指示器样式 */
.loading-indicator {
  display: flex;
  justify-content: center;
  padding: 1rem;
}

.dot-pulse {
  position: relative;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background-color: #4caf50;
  animation: dot-pulse 1.5s infinite;
}

.dot-pulse::before, .dot-pulse::after {
  content: "";
  position: absolute;
  display: inline-block;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background-color: #4caf50;
}

.dot-pulse::before {
  left: -20px;
  animation: dot-pulse 1.5s -0.5s infinite;
}

.dot-pulse::after {
  left: 20px;
  animation: dot-pulse 1.5s -1s infinite;
}

@keyframes dot-pulse {
  0%, 100% {
    opacity: 0;
    transform: scale(0.5);
  }
  50% {
    opacity: 1;
    transform: scale(1);
  }
}

/* 错误消息样式 */
.error-message {
  color: #f44336;
  padding: 1rem;
  background-color: #ffebee;
  border-radius: 8px;
  margin-bottom: 1rem;
}

/* 输入区域样式 */
.chat-input {
  display: flex;
  gap: 0.5rem;
  padding: 1rem;
  border-top: 1px solid #e0e0e0;
  background-color: #f5f5f5;
}

.chat-input textarea {
  flex: 1;
  padding: 0.8rem;
  border: 1px solid #ccc;
  border-radius: 4px;
  resize: none;
  min-height: 60px;
}

.chat-input button {
  padding: 0.8rem 1.5rem;
  background-color: #2196f3;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.3s;
}

.chat-input button:hover:not(:disabled) {
  background-color: #1976d2;
}

.chat-input button:disabled {
  background-color: #cccccc;
  cursor: not-allowed;
}

/* Markdown样式 */
.message-content h1, .message-content h2, .message-content h3 {
  font-size: 1.2rem;
  margin: 1rem 0 0.5rem 0;
}

.message-content p {
  margin-bottom: 0.5rem;
  line-height: 1.5;
}

.message-content pre {
  background-color: #f5f5f5;
  padding: 1rem;
  border-radius: 4px;
  overflow-x: auto;
  margin: 0.5rem 0;
}

.message-content code {
  font-family: monospace;
}
</style>

配置主应用

修改src/App.vue

vue

<template>
  <ChatInterface />
</template>

<script setup>
import ChatInterface from './components/ChatInterface.vue';
</script>

<style>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}

body {
  background-color: #f0f0f0;
}
</style>

修改src/main.js以使用 Pinia:

javascript

import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';

// 创建Pinia实例
const pinia = createPinia();

// 创建应用并使用Pinia
createApp(App)
  .use(pinia)
  .mount('#app');
</script>

## 核心功能实现

### 对话记忆管理

在前面的实现中,我们已经使用LangChain.js的`BufferMemory`来管理对话记忆:

```javascript
// 创建对话链和记忆存储
const memory = new BufferMemory({ returnMessages: true });
const chain = new ConversationChain({ llm: model, memory });

BufferMemory会自动存储对话历史,并在每次调用时将其传递给语言模型。我们还可以通过以下方式手动管理记忆:

javascript

// 保存对话上下文
await memory.saveContext(
  { input: "Hello" }, // 用户输入
  { output: "Hello! How can I help you today?" } // AI输出
);

// 加载对话历史
const history = await memory.loadMemoryVariables({});
console.log(history.chat_history);

模型参数调整

我们可以通过修改 LangChain.js 的模型配置来调整生成参数:

javascript

const model = new ChatOllama({
  baseUrl: "http://localhost:11434",
  model: "llama3",
  temperature: 0.7, // 控制输出随机性,0表示确定性输出,1表示最大随机性
  maxTokens: 1024, // 最大输出token数
  topP: 0.9, // 控制输出多样性,0.9表示只考虑前90%概率的token
  frequencyPenalty: 0, // 控制重复 token 的惩罚
  presencePenalty: 0 // 控制主题多样性的惩罚
});

多模型支持

通过修改 Ollama 的模型名称,我们可以轻松切换不同的语言模型:

javascript

// 使用Mistral模型
const model = new ChatOllama({
  baseUrl: "http://localhost:11434",
  model: "mistral"
});

// 使用Gemma模型
const model = new ChatOllama({
  baseUrl: "http://localhost:11434",
  model: "gemma"
});

在前端界面中,我们已经实现了模型选择器,可以方便地切换不同模型。

测试与效果展示

启动应用

  1. 启动 Ollama 服务:

bash

ollama serve

  1. 启动后端服务器:

bash

cd backend
node server.js

  1. 启动 Vue 前端:

bash

npm run serve

  1. 打开浏览器访问http://localhost:8080

基本对话测试

尝试输入以下消息进行测试:

  • "你好,我叫小明"
  • "我叫什么名字?"
  • "能给我讲个笑话吗?"
  • "这个笑话是什么意思?"

流式响应测试

观察 AI 回复是否实时显示,而不是等待完整响应生成。

模型切换测试

尝试切换不同模型,观察响应风格和能力的差异。

遇到的问题及解决方案

CORS 跨域问题

问题:前端调用后端 API 时出现跨域错误。

解决方案

  1. 配置 Ollama 的 CORS 设置,允许前端域名访问
  2. 使用 Express 的 cors 中间件:

javascript

const cors = require('cors');
app.use(cors({
  origin: 'http://localhost:8080',
  methods: ['GET', 'POST'],
  allowedHeaders: ['Content-Type']
}));

流式响应实现

问题:如何在 Vue3 中实现流式响应显示。

解决方案
使用 Fetch API 的 ReadableStream 和 TextDecoder:

javascript

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, { stream: true });
  // 处理和显示chunk
}

对话记忆管理

问题:如何在多次对话中保持上下文。

解决方案
使用 LangChain.js 的 BufferMemory:

javascript

const memory = new BufferMemory({ returnMessages: true });
const chain = new ConversationChain({ llm: model, memory });

模型加载缓慢

问题:首次启动时模型加载时间过长。

解决方案

  1. 使用较小的模型(如 7B 参数版本)
  2. 增加 Ollama 的 keep_alive 参数,使模型在内存中保持更长时间:

javascript

const model = new ChatOllama({
  // ...其他配置
  keepAlive: "5m" // 模型在内存中保持5分钟
});

总结与扩展

本文总结

本文详细介绍了如何使用 LangChain.js、Ollama 和 Vue3 构建本地聊天机器人。我们从环境搭建开始,逐步实现了后端服务和前端界面,并探讨了对话记忆管理、模型参数调整等核心功能。通过这个项目,我们可以在本地环境中安全、高效地使用 AI 聊天功能,无需依赖云端服务。

未来扩展方向

  1. 功能扩展

     
    • 添加文件上传功能,支持 PDF、TXT 等文档的问答
    • 实现语音输入输出功能
    • 添加对话历史保存和加载功能
  2. 性能优化

     
    • 实现模型预热功能,减少首次加载时间
    • 添加对话历史摘要功能,优化长对话性能
    • 使用 Web Worker 处理复杂计算,避免 UI 阻塞
  3. 界面美化

     
    • 添加更多主题样式
    • 实现响应式设计,支持移动设备
    • 添加表情和富文本支持
  4. 高级功能

     
    • 实现 RAG(检索增强生成)功能,结合外部知识库
    • 添加多轮对话流程控制
    • 支持自定义系统提示,定制 AI 行为

通过不断优化和扩展,这个本地聊天机器人可以满足更多个性化需求,成为我们日常工作和学习的得力助手。

参考资料

补充:安装必要依赖

在前端项目中,我们使用了 marked 和 highlight.js 来处理 Markdown 格式和代码高亮,需要安装这些依赖:

bash

# 安装前端依赖
npm install marked highlight.js

# 安装后端依赖
cd backend
npm install @langchain/community @langchain/core

补充:前端组件导入

src/components/ChatInterface.vue中添加以下导入语句:

javascript

import { marked } from 'marked';
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';

并初始化 marked:

javascript

// 在组件 setup 中初始化marked
marked.setOptions({
  highlight: function(code, lang) {
    if (lang && hljs.getLanguage(lang)) {
      return hljs.highlight(code, { language: lang }).value;
    }
    return hljs.highlightAuto(code).value;
  }
});

补充:后端依赖安装

在后端项目中,需要安装 LangChain 相关依赖:

bash

cd backend
npm install @langchain/community @langchain/core express cors

补充:安装 LangChain 核心包

在后端项目中,需要安装 LangChain 核心包:

bash

cd backend
npm install langchain

安全提示:XSS 风险

ChatInterface.vue组件中,我们使用了v-html指令来渲染 Markdown 内容:

vue

<div class="message-content" v-html="formatMessage(msg.content)"></div>

这可能会带来 XSS(跨站脚本)安全风险,因为v-html会直接插入 HTML 内容。在本地聊天机器人的场景下,风险较低,因为所有内容都来自本地模型。但在生产环境中,建议使用更安全的 Markdown 渲染方式,例如使用vue-markdown-render等专门的 Vue 组件,或对渲染内容进行安全过滤。

补充:完整的后端依赖列表

后端项目的package.json文件应包含以下依赖:

json

{
  "dependencies": {
    "@langchain/community": "^0.2.0",
    "@langchain/core": "^0.2.0",
    "cors": "^2.8.5",
    "express": "^4.18.2",
    "langchain": "^0.2.0",
    "ollama": "^0.5.0"
  }
}

plaintext


Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐