LangChain.js+Ollama+Vue3 本地聊天机器人完整实现
引言
在当今 AI 技术快速发展的时代,聊天机器人已经成为人机交互的重要方式。然而,大多数聊天机器人依赖云端服务,存在数据隐私和网络依赖的问题。本地聊天机器人则可以很好地解决这些问题,同时还能提供更低的延迟和更高的响应速度。
本文将介绍如何使用 LangChain.js、Ollama 和 Vue3 构建一个完整的本地聊天机器人。我们将实现从环境搭建到前后端开发的全过程,最终打造一个功能完善、界面友好的本地聊天应用。
技术栈概述
- LangChain.js:用于构建基于语言模型的应用程序框架,提供了丰富的工具和接口来简化 LLM 应用开发
- Ollama:用于在本地运行开源大型语言模型的工具,支持 Llama 2、Mistral 等多种模型
- Vue3:用于构建用户界面的前端框架,采用组件化开发模式,提供了响应式数据绑定和组合式 API
环境搭建
安装 Ollama
- 访问Ollama 官方网站下载并安装 Ollama
- 打开终端,执行以下命令拉取并运行模型:
bash
ollama pull llama3
ollama run llama3
- 验证 Ollama 服务是否正常运行:
bash
curl http://localhost:11434/api/chat -d '{"model":"llama3","messages":[{"role":"user","content":"Hello"}]}'
配置 CORS
为了解决前端跨域问题,需要配置 Ollama 的 CORS 设置:
Windows 系统:
- 打开 "控制面板" -> "系统" -> "高级系统设置" -> "环境变量"
- 创建新的系统环境变量:
- 变量名:
OLLAMA_ORIGINS - 变量值:
http://localhost:8080(Vue 开发服务器地址)
- 变量名:
- 重启 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 服务器
- 创建后端目录并初始化:
bash
mkdir backend
cd backend
npm init -y
npm install express cors @langchain/community @langchain/core ollama
- 创建服务器文件
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"
});
在前端界面中,我们已经实现了模型选择器,可以方便地切换不同模型。
测试与效果展示
启动应用
- 启动 Ollama 服务:
bash
ollama serve
- 启动后端服务器:
bash
cd backend
node server.js
- 启动 Vue 前端:
bash
npm run serve
- 打开浏览器访问
http://localhost:8080
基本对话测试
尝试输入以下消息进行测试:
- "你好,我叫小明"
- "我叫什么名字?"
- "能给我讲个笑话吗?"
- "这个笑话是什么意思?"
流式响应测试
观察 AI 回复是否实时显示,而不是等待完整响应生成。
模型切换测试
尝试切换不同模型,观察响应风格和能力的差异。
遇到的问题及解决方案
CORS 跨域问题
问题:前端调用后端 API 时出现跨域错误。
解决方案:
- 配置 Ollama 的 CORS 设置,允许前端域名访问
- 使用 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 });
模型加载缓慢
问题:首次启动时模型加载时间过长。
解决方案:
- 使用较小的模型(如 7B 参数版本)
- 增加 Ollama 的 keep_alive 参数,使模型在内存中保持更长时间:
javascript
const model = new ChatOllama({
// ...其他配置
keepAlive: "5m" // 模型在内存中保持5分钟
});
总结与扩展
本文总结
本文详细介绍了如何使用 LangChain.js、Ollama 和 Vue3 构建本地聊天机器人。我们从环境搭建开始,逐步实现了后端服务和前端界面,并探讨了对话记忆管理、模型参数调整等核心功能。通过这个项目,我们可以在本地环境中安全、高效地使用 AI 聊天功能,无需依赖云端服务。
未来扩展方向
-
功能扩展:
- 添加文件上传功能,支持 PDF、TXT 等文档的问答
- 实现语音输入输出功能
- 添加对话历史保存和加载功能
-
性能优化:
- 实现模型预热功能,减少首次加载时间
- 添加对话历史摘要功能,优化长对话性能
- 使用 Web Worker 处理复杂计算,避免 UI 阻塞
-
界面美化:
- 添加更多主题样式
- 实现响应式设计,支持移动设备
- 添加表情和富文本支持
-
高级功能:
- 实现 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
更多推荐

所有评论(0)