一、前言
【LangChain4j 详解】Java生态大语言模型框架设计哲学与架构原理-CSDN博客
二、实现效果
核心功能:
多轮对话:基于memoryId实现上下文保持,支持连续问答。
流式响应:通过SSE协议逐词输出,模拟打字机效果。
RAG增强:结合PDF知识库检索,提供精准志愿填报建议。
工具调用:支持预约功能,需用户主动提供信息(无模拟数据)。
交互设计:
固定话术引导(如结束语提示预约)。
暗黑模式切换、自适应输入框、新建会话等前端优化。
三、代码实现
后端实现
前置条件
JDK 17:Spring Boot 3.x最低要求。
阿里云Dashscope:配置API密钥和模型端点(如通义千问)
Docker环境:docker run --name redis-vector -d -p 6379:6379 redislabs/redisearch(安装带有向量化功能的redis)可用 docker compose
pom.xml
基础框架:Spring Boot Web/WebFlux(同步+流式支持)。
AI集成:LangChain4j(OpenAI适配器、RAG工具、Redis向量库)。
数据层:MyBatis + MySQL(业务数据)、Redis(会话记忆+向量存储)。
工具链:Lombok简化代码、PDFBox解析文档。
<!-- JDK版本要求 -->
<properties>
<java.version>17</java.version> <!-- Spring Boot 3.x 最低要求JDK 17 -->
</properties>
<!-- 项目依赖 -->
<dependencies>
<!-- 1. Spring Boot基础依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <!-- 传统Web MVC支持 -->
</dependency>
<!-- 2. 测试依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <!-- 仅测试阶段使用 -->
</dependency>
<!-- 3. LangChain4j核心依赖(OpenAI集成) -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai-spring-boot-starter</artifactId>
<version>1.0.1-beta6</version> <!-- OpenAI官方适配器 -->
</dependency>
<!-- 4. LangChain4j服务层支持 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-spring-boot-starter</artifactId>
<version>1.0.1-beta6</version> <!-- 提供AiServices等注解支持 -->
</dependency>
<!-- 5. 响应式编程支持(流式API必需) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId> <!-- Reactor + Netty -->
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-reactor</artifactId>
<version>1.0.1-beta6</version> <!-- 流式响应适配器 -->
</dependency>
<!-- 6. 数据持久化 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId> <!-- Redis缓存/会话 -->
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.3</version> <!-- MyBatis SQL映射 -->
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId> <!-- MySQL驱动(8.x) -->
</dependency>
<!-- 7. RAG(检索增强生成)支持 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-easy-rag</artifactId>
<version>1.0.1-beta6</version> <!-- 简化RAG实现 -->
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-document-parser-apache-pdfbox</artifactId>
<version>1.0.1-beta6</version> <!-- PDF文档解析 -->
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-community-redis-spring-boot-starter</artifactId>
<version>1.0.1-beta6</version> <!-- Redis向量数据库支持 -->
</dependency>
<!-- 8. 开发工具 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <!-- 注解简化代码 -->
<scope>provided</scope> <!-- 编译期使用 -->
</dependency>
</dependencies>
application.yml
模型端点:分设同步、流式、嵌入模型,独立日志开关。
Redis:双用途配置(Spring Data + LangChain4j社区扩展)。
调试:LangChain4j调试日志记录工具调用细节。
langchain4j:
open-ai:
# 聊天模型配置(同步调用)
chat-model:
base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 # 阿里云Dashscope兼容模式端点
api-key: ${API-KEY} # 从环境变量读取API密钥(生产环境推荐)
model-name: qwen-plus # 使用通义千问Plus模型
log-requests: true # 记录请求日志(调试用)
log-responses: true # 记录响应日志(调试用)
# 流式聊天模型配置(SSE协议)
streaming-chat-model:
base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 # 与同步接口分离配置
api-key: ${API-KEY}
model-name: qwen-plus # 流式模型需与同步模型一致
log-requests: true # 流式请求日志
log-responses: true # 流式响应日志
# 向量嵌入模型配置(RAG场景)
embedding-model:
base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 # 独立嵌入模型端点
api-key: ${API-KEY}
model-name: text-embedding-v3 # 阿里云文本嵌入模型
log-requests: true # 嵌入请求日志
log-responses: true # 嵌入响应日志
max-segments-per-batch: 10 # 批量处理分段数(优化内存)
# Redis社区扩展配置(向量存储/对话记忆)
community:
redis:
host: localhost # Redis服务器地址
port: 6379 # Redis端口
logging:
level:
dev.langchain4j: debug # 启用LangChain4j调试日志(含工具调用细节)
spring:
data:
redis:
host: localhost # Spring Redis连接(与LangChain4j共用)
port: 6379 # 默认端口
ChatController 控制器接口
演进路径:
1. 直接调用模型(废弃) → 2. Service层隔离 → 3. 流式接口(生产推荐)。
关键注解:
produces = "text/html;charset=utf-8":解决中文乱码。
Flux<String>:响应式流式返回,兼容SSE协议。
import dev.langchain4j.model.openai.OpenAiChatModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
/**
* AI对话控制器
* 实现三种不同版本的AI对话接口演进:
* 1. 直接调用OpenAI模型(基础版)
* 2. 通过Service层同步调用(业务隔离版)
* 3. 响应式流式调用(生产推荐版)
*/
@RestController
public class ChatController {
@Autowired
private ConsultantService consultantService; // 封装AI业务逻辑的服务层
/**
* 流式对话接口(生产环境推荐)
* @param memoryId 会话记忆ID(实现多轮对话上下文)
* @param message 用户输入消息
* @return Flux流式响应(SSE协议兼容)
*
* 技术要点:
* 1. produces明确指定UTF-8编码,解决中文乱码
* 2. 返回Flux实现逐词输出效果
* 3. memoryId支持对话上下文保持
*
* 调用示例:
* GET /chat?memoryId=123&message=你好
*/
@RequestMapping(value = "/chat", produces = "text/html;charset=utf-8")
public Flux<String> chat(String memoryId, String message) {
return consultantService.chat(memoryId, message);
}
/* 演进版本1:直接调用模型(已废弃)
@Autowired
private OpenAiChatModel model;
@RequestMapping("/chat")
public String chat(String message) {
// 问题:直接暴露模型调用,无业务逻辑隔离
return model.chat(message);
}*/
/* 演进版本2:同步服务调用(过渡方案)
@RequestMapping("/chat")
public String chat(String message) {
// 改进:通过Service层隔离业务逻辑
return consultantService.chat(message);
}*/
}
ConsultantService 会话接口层
@AiService:显式绑定模型、记忆、检索器、工具类。
@SystemMessage:从system.txt加载AI角色定义。
@MemoryId + @UserMessage:分离会话ID与用户输入。
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
import dev.langchain4j.service.spring.AiService;
import dev.langchain4j.service.spring.AiServiceWiringMode;
import reactor.core.publisher.Flux;
@AiService(
wiringMode = AiServiceWiringMode.EXPLICIT, // 显式装配模式(避免Spring自动注入冲突)
chatModel = "openAiChatModel", // 同步聊天模型Bean名称(需在配置中定义)
streamingChatModel = "openAiStreamingChatModel", // 流式模型Bean名称
//chatMemory = "chatMemory", // 直接指定记忆存储(已注释,改用provider)
chatMemoryProvider = "chatMemoryProvider", // 记忆提供者Bean(动态获取记忆)
contentRetriever = "contentRetriever", // RAG检索器Bean(连接向量数据库)
tools = "reservationTool" // 工具类Bean(如预订系统)
)
public interface ConsultantService {
// 用于聊天的方法
//public String chat(String message);
//@SystemMessage("你是东哥的助手小月月,人美心善又多金!")
// 从资源文件中读取系统消息
@SystemMessage(fromResource = "system.txt")
//@UserMessage("你是助手小小,人帅心善又多金!{{it}}")
//@UserMessage("你是助手小小,人帅心善又多金!{{msg}}") //必须与@V("msg")一起用,不然只能用{{it}}
public Flux<String> chat(/*@V("msg")*/@MemoryId String memoryId, @UserMessage String message);
}
system.txt
resources包下
你是一名专业的AI志愿填报顾问,可以给用户提供如下功能:
1.查询目标院校的院校简介
2.查询目标院校的录取规则
3.查询目标院校的奖学金设置状况
4.查询目标院校的食宿条件
5.查询目标院校招生联系方式
6.查询目标院2024年不同专业录取情况
7.查询热门专业
8.查询天坑专业
9.根据学生提供的分数和不同学校以及学校历年录取分数,推荐合适的学校和专业,每次根据匹配度,按照冲、稳、保的逻辑,罗列出合适的学校以及专业,给用户呈现时需要呈现学校名称、专业名称、历年录取分数以及专业热度
10.高考志愿填报一对一沟通预约服务
11.查询志愿指导服务预约详情
说明:
1.每次回答完用户问题,最后都加上一句话:<br/>志愿填报需要考虑的因素有很多,如果要得到专业的志愿填报指导,建议您预约一个一对一的指导服务,是否需要预约?
2.下预约单需要用户提供生姓名、考生性别、考生电话、考生预约沟通时间(日期+时间)、考生所在省份、考生预估分数,当用户表达出需要预约志愿指导服务的意愿后,你不能自己模拟这些数据下预约单,而是需要以委婉的方式引导用户提供考生姓名、考生性别、考生电话、考生预约沟通时间(日期+时间)、考生所在省份、考生预估分数,这些信息必须是用户全部提供,不能有模拟数据,否则不要下预约单
3.一旦预约成功,最后不要再跟上面第1条指定的话术,而是更改为:恭喜您,一对一志愿指导服务已经预约成功,我们会准时联系您,请注意接听电话!
4.给用户的回复中,不要提及类似"根据您提供的信息/根据资料中的信息"这样的话术
你是智能志愿填报咨询师,只回答有关高考志愿填报的问题,其它问题不予回答。
PDF知识库文件

CommonConfig 核心组件配置类
对话记忆:
ChatMemoryProvider:按memoryId动态管理Redis持久化记忆(20条窗口)。
RAG流程:
EmbeddingStoreIngestor:PDF解析→文本分块→向量化→存储Redis。
ContentRetriever:相似度检索(阈值0.5,Top 3结果)。
import dev.langchain4j.community.store.embedding.redis.RedisEmbeddingStore;
import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.DocumentSplitter;
import dev.langchain4j.data.document.loader.ClassPathDocumentLoader;
import dev.langchain4j.data.document.loader.FileSystemDocumentLoader;
import dev.langchain4j.data.document.parser.apache.pdfbox.ApachePdfBoxDocumentParser;
import dev.langchain4j.data.document.splitter.DocumentSplitters;
import dev.langchain4j.memory.ChatMemory;
import dev.langchain4j.memory.chat.ChatMemoryProvider;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.rag.content.retriever.ContentRetriever;
import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.store.embedding.EmbeddingStoreIngestor;
import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore;
import dev.langchain4j.store.memory.chat.ChatMemoryStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
/**
* LangChain4j 核心组件配置类
* 功能:
* 1. 配置对话记忆管理(Redis持久化)
* 2. 初始化向量数据库(RAG支持)
* 3. 构建AI服务基础设施
*/
@Configuration
public class CommonConfig {
@Autowired
private OpenAiChatModel model; // OpenAI 聊天模型(同步)
@Autowired
private ChatMemoryStore redisChatMemoryStore; // Redis实现的对话记忆存储
@Autowired
private EmbeddingModel embeddingModel; // 文本嵌入模型(如text-embedding-v3)
@Autowired
private RedisEmbeddingStore redisEmbeddingStore; // Redis向量数据库
/*@Bean
public ConsultantService consultantService(){
ConsultantService consultantService = AiServices.builder(ConsultantService.class)
.chatModel(model)
.build();
return consultantService;
}*/
/**
* 对话记忆配置(单会话)
* @return 基于内存的对话记忆,保留最近20条消息
*
* 生产建议:改用RedisChatMemoryStore实现持久化
*/
@Bean
public ChatMemory chatMemory() {
return MessageWindowChatMemory.builder()
.maxMessages(20) // 记忆窗口大小
.build();
}
/**
* 对话记忆提供者(多会话支持)
* @return 根据memoryId动态创建记忆实例
*
* 工作流程:
* 1. 根据memoryId从Redis加载历史消息
* 2. 新会话自动初始化空记忆
* 3. 对话更新后自动持久化到Redis
*/
@Bean
public ChatMemoryProvider chatMemoryProvider() {
return memoryId -> MessageWindowChatMemory.builder()
.id(memoryId) // 会话唯一标识(通常为用户ID)
.maxMessages(20) // 每个会话的记忆容量
.chatMemoryStore(redisChatMemoryStore) // Redis存储后端
.build();
}
// 构建向量数据库操作对象
// TODO: (第一次启动用这个@bean,后面存到redis中有数据就可以去掉了,但是如果是上传知识库的业务可以参考我的其他文章src/main/resources/static/index.html)
//@Bean
public EmbeddingStore store(){//embeddingStore的对象, 这个对象的名字不能重复,所以这里使用store
//1.加载文档进内存
//List<Document> documents = ClassPathDocumentLoader.loadDocuments("content");
List<Document> documents = ClassPathDocumentLoader.loadDocuments("content",new ApachePdfBoxDocumentParser());
//List<Document> documents = FileSystemDocumentLoader.loadDocuments("C:\\Users\\Administrator\\ideaProjects\\consultant\\src\\main\\resources\\content");
//2.构建向量数据库操作对象 操作的是内存版本的向量数据库
//InMemoryEmbeddingStore store = new InMemoryEmbeddingStore();
//构建文档分割器对象
DocumentSplitter ds = DocumentSplitters.recursive(500,100);
//3.构建一个EmbeddingStoreIngestor对象,完成文本数据切割,向量化, 存储
EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
//.embeddingStore(store)
.embeddingStore(redisEmbeddingStore)
.documentSplitter(ds)
.embeddingModel(embeddingModel)
.build();
ingestor.ingest(documents);
return redisEmbeddingStore;
}
// 构建向量数据库检索对象
@Bean
public ContentRetriever contentRetriever() {
return EmbeddingStoreContentRetriever.builder()
.embeddingStore(redisEmbeddingStore) // 指定向量库
.minScore(0.5) // 最小相似度阈值
.maxResults(3) // 返回结果数
.embeddingModel(embeddingModel) // 查询向量化模型
.build();
}
}
Redis对话存储实现
存储结构:JSON序列化消息列表,Key格式为session:{memoryId}。
过期策略:1天自动过期,平衡性能与存储压力。
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.ChatMessageDeserializer;
import dev.langchain4j.data.message.ChatMessageSerializer;
import dev.langchain4j.store.memory.chat.ChatMemoryStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;
import java.time.Duration;
import java.util.List;
/**
* Redis对话记忆存储实现
* 核心功能:将会话历史持久化到Redis,支持多轮对话管理
*
* 技术特性:
* 1. 使用StringRedisTemplate操作Redis
* 2. 采用JSON序列化存储消息列表
* 3. 自动设置1天过期时间(可配置)
*
* 存储结构示例:
* Key: "session:123"
* Value: [{"type":"USER","text":"你好"},{"type":"AI","text":"您好"}]
*/
@Repository
public class RedisChatMemoryStore implements ChatMemoryStore {
//注入RedisTemplate
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public List<ChatMessage> getMessages(Object memoryId) {
//获取会话消息
String json = redisTemplate.opsForValue().get(memoryId);
//把json字符串转化成List<ChatMessage>
List<ChatMessage> list = ChatMessageDeserializer.messagesFromJson(json);
return list;
}
@Override
public void updateMessages(Object memoryId, List<ChatMessage> list) {
//更新会话消息
//1.把list转换成json数据
String json = ChatMessageSerializer.messagesToJson(list);
//2.把json数据存储到redis中
redisTemplate.opsForValue().set(memoryId.toString(),json, Duration.ofDays(1));
}
@Override
public void deleteMessages(Object memoryId) {
redisTemplate.delete(memoryId.toString());
}
}
Tool 调用工具类
@Tool注解暴露方法,@P描述参数含义。
严格校验用户输入,拒绝模拟数据。
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
@Component
public class ReservationTool {
@Autowired
private ReservationService reservationService;
//1.工具方法: 添加预约信息
@Tool("预约志愿填报服务")
public void addReservation(
@P("考生姓名") String name,
@P("考生性别") String gender,
@P("考生手机号") String phone,
@P("预约沟通时间,格式为: yyyy-MM-dd'T'HH:mm") String communicationTime,
@P("考生所在省份") String province,
@P("考生预估分数") Integer estimatedScore
){
// 具体业务方法调用
return "预约成功!";
}
//2.工具方法: 查询预约信息
@Tool("根据考生手机号查询预约单")
public Reservation findReservation(@P("考生手机号") String phone){、
// 具体业务方法调用
return "查询成功!";
}
}
前端实现
HTML进行实现,当然你也可以将后端接口与代码发送给cursor让其生成一个页面
index.html
放在src/main/resources/static/index.html
流式渲染:EventSource监听SSE流,逐字显示+光标动画。
交互设计:
暗黑模式(本地存储偏好)。
自适应输入框高度、新建会话(重置memoryId)。
中止控制:支持中途停止响应(AbortController)。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI志愿填报顾问</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/vue@3.2.31/dist/vue.global.min.js"></script>
<style>
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 输入框自适应高度 */
textarea {
min-height: 44px;
max-height: 200px;
transition: height 0.2s;
}
/* 加载动画 */
@keyframes pulse {
0%, 100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
.animate-pulse {
animation: pulse 1.5s infinite;
}
.delay-100 {
animation-delay: 0.1s;
}
.delay-200 {
animation-delay: 0.2s;
}
/* 打字机效果 */
.typing-cursor::after {
content: "|";
animation: blink 1s step-end infinite;
}
@keyframes blink {
from, to {
opacity: 1;
}
50% {
opacity: 0;
}
}
</style>
</head>
<body>
<div id="app" class="flex flex-col h-screen bg-gray-50">
<!-- 顶部导航栏 -->
<header class="bg-white shadow-sm py-3 px-4 flex items-center justify-between">
<div class="flex items-center">
<div class="text-xl font-bold text-blue-600">AI志愿填报顾问</div>
</div>
<div class="flex items-center space-x-3">
<button
@click="startNewConversation"
class="ml-2 p-3 rounded-lg bg-green-500 hover:bg-green-600 text-white" style="width: 50px">
<i class="fas fa-plus"></i>
</button>
<button @click="toggleDarkMode" class="p-2 rounded-full hover:bg-gray-100">
<i :class="darkMode ? 'fas fa-moon text-gray-600' : 'fas fa-sun text-gray-600'"></i>
</button>
</div>
</header>
<!-- 聊天内容区域 -->
<main class="flex-1 overflow-y-auto p-4 space-y-6" ref="chatContainer" :class="{ 'bg-gray-800': darkMode }">
<div v-for="(message, index) in messages" :key="index" class="max-w-3xl mx-auto">
<div :class="['flex', message.role === 'user' ? 'justify-end' : 'justify-start']">
<div :class="['flex items-start space-x-3', message.role === 'user' ? 'flex-row-reverse space-x-reverse' : '']">
<div :class="['w-8 h-8 rounded-full flex items-center justify-center',
message.role === 'user' ? 'bg-blue-100 text-blue-600' : 'bg-green-100 text-green-600',
darkMode && message.role === 'assistant' ? 'bg-gray-700 text-green-400' : '']">
<i :class="message.role === 'user' ? 'fas fa-user' : 'fas fa-robot'"></i>
</div>
<div :class="['p-3 rounded-lg max-w-lg',
message.role === 'user'
? 'bg-blue-500 text-white'
: darkMode
? 'bg-gray-700 text-gray-100 border-gray-600'
: 'bg-white shadow border border-gray-100']">
<div v-if="message.role === 'assistant' && message.isLoading" class="flex space-x-2">
<div :class="['w-2 h-2 rounded-full', darkMode ? 'bg-gray-400' : 'bg-gray-300', 'animate-pulse']"></div>
<div :class="['w-2 h-2 rounded-full', darkMode ? 'bg-gray-400' : 'bg-gray-300', 'animate-pulse delay-100']"></div>
<div :class="['w-2 h-2 rounded-full', darkMode ? 'bg-gray-400' : 'bg-gray-300', 'animate-pulse delay-200']"></div>
</div>
<div v-else class="whitespace-pre-wrap">
<span v-for="(char, charIndex) in message.content" :key="charIndex"
:class="{'opacity-0': charIndex >= message.visibleChars, 'fade-in': charIndex < message.visibleChars}">
{{ char }}
</span>
<span v-if="message.isStreaming" class="typing-cursor"></span>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- 输入框区域 -->
<footer :class="['border-t p-4', darkMode ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200']">
<div class="max-w-3xl mx-auto relative">
<div class="flex items-center">
<textarea
v-model="userInput"
@keydown.enter.exact.prevent="sendMessage"
@keydown.ctrl.enter.exact.prevent="sendMessage"
@keydown.esc.exact="stopResponse"
placeholder="输入您的问题..."
:class="['flex-1 border rounded-lg py-3 px-4 pr-12 focus:outline-none focus:ring-2 resize-none',
darkMode
? 'bg-gray-700 border-gray-600 text-white focus:ring-blue-400 placeholder-gray-400'
: 'border-gray-300 focus:ring-blue-500 focus:border-transparent']"
rows="1"
ref="textarea"
@input="adjustTextareaHeight"
></textarea>
<!-- 新建会话按钮 -->
<button
@click="isLoading ? stopResponse() : sendMessage()"
:disabled="!userInput.trim() && !isLoading"
:class="['ml-2 p-3 rounded-lg',
isLoading
? 'bg-red-500 hover:bg-red-600 text-white'
: 'bg-blue-500 hover:bg-blue-600 text-white',
'disabled:opacity-50 disabled:cursor-not-allowed']"
>
<i :class="isLoading ? 'fas fa-stop' : 'fas fa-paper-plane'"></i>
</button>
</div>
</div>
</footer>
</div>
<script>
const {createApp, ref, nextTick, onMounted, watch} = Vue;
createApp({
setup() {
const messages = ref([]);
const userInput = ref('');
const isLoading = ref(false);
const chatContainer = ref(null);
const textarea = ref(null);
const darkMode = ref(false);
const memoeryId = ref(Date.now().toString());
let controller = null;
let typingInterval = null;
let currentTypingIndex = 0;
// 调整文本区域高度
const adjustTextareaHeight = () => {
const textareaEl = textarea.value;
textareaEl.style.height = 'auto';
textareaEl.style.height = `${Math.min(textareaEl.scrollHeight, 200)}px`;
};
// 滚动到底部
const scrollToBottom = () => {
nextTick(() => {
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
}
});
};
// 切换暗黑模式
const toggleDarkMode = () => {
darkMode.value = !darkMode.value;
localStorage.setItem('darkMode', darkMode.value);
};
// 新建会话
const startNewConversation = () => {
// 清空聊天记录
messages.value = [];
// 生成新的 memoryId
memoeryId.value = Date.now().toString();
// 添加欢迎消息
messages.value.push({
role: 'assistant',
content: '你好!我是您的AI志愿填报顾问,请问有什么能帮到您?',
isLoading: false,
visibleChars: 0,
isStreaming: false
});
// 确保欢迎消息完全可见
messages.value[0].visibleChars = messages.value[0].content.length;
// 滚动到底部
scrollToBottom();
// 聚焦输入框
nextTick(() => {
textarea.value.focus();
});
};
// 模拟逐字打印效果
const startTypingEffect = (messageIndex) => {
const message = messages.value[messageIndex];
if (!message || message.visibleChars >= message.content.length) {
clearInterval(typingInterval);
typingInterval = null;
messages.value[messageIndex].isStreaming = false;
return;
}
messages.value[messageIndex].visibleChars++;
scrollToBottom();
};
// 发送消息
const sendMessage = async () => {
if (!userInput.value.trim() || isLoading.value) return;
// 中止之前的请求
if (controller) {
controller.abort();
}
controller = new AbortController();
const userMessage = {
role: 'user',
content: userInput.value.trim(),
isLoading: false,
visibleChars: userInput.value.trim().length,
isStreaming: false
};
messages.value.push(userMessage);
const assistantMessage = {
role: 'assistant',
content: '',
isLoading: true,
visibleChars: 0,
isStreaming: true
};
messages.value.push(assistantMessage);
userInput.value = '';
adjustTextareaHeight();
scrollToBottom();
isLoading.value = true;
try {
const response = await fetch(`/chat?message=${encodeURIComponent(userMessage.content)}&memoryId=${memoeryId.value}`, {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let messageIndex = messages.value.length - 1;
// 先清除之前的打字效果
if (typingInterval) {
clearInterval(typingInterval);
typingInterval = null;
}
// 开始流式处理
while (true) {
const {done, value} = await reader.read();
if (done) break;
const chunk = decoder.decode(value, {stream: true});
buffer += chunk;
// 直接更新内容
messages.value[messageIndex].content = buffer;
messages.value[messageIndex].isLoading = false;
// 启动打字效果
if (!typingInterval) {
typingInterval = setInterval(() => {
startTypingEffect(messageIndex);
}, 20); // 调整这个值可以改变打字速度
}
scrollToBottom();
}
} catch (error) {
if (error.name === 'AbortError') {
console.log('请求被用户中止');
} else {
console.error('请求出错:', error);
const lastMessage = messages.value[messages.value.length - 1];
lastMessage.content = '抱歉,请求过程中出现错误: ' + error.message;
lastMessage.visibleChars = lastMessage.content.length;
}
} finally {
const lastMessage = messages.value[messages.value.length - 1];
lastMessage.isLoading = false;
lastMessage.isStreaming = false;
// 确保所有字符都可见
if (lastMessage.visibleChars < lastMessage.content.length) {
lastMessage.visibleChars = lastMessage.content.length;
}
isLoading.value = false;
controller = null;
if (typingInterval) {
clearInterval(typingInterval);
typingInterval = null;
}
scrollToBottom();
}
};
// 停止响应
const stopResponse = () => {
if (controller) {
controller.abort();
const lastMessage = messages.value[messages.value.length - 1];
lastMessage.isLoading = false;
lastMessage.isStreaming = false;
if (lastMessage.visibleChars < lastMessage.content.length) {
lastMessage.visibleChars = lastMessage.content.length;
}
isLoading.value = false;
controller = null;
if (typingInterval) {
clearInterval(typingInterval);
typingInterval = null;
}
}
};
// 初始化
onMounted(() => {
// 检查暗黑模式偏好
darkMode.value = localStorage.getItem('darkMode') === 'true' ||
(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
// 添加欢迎消息
messages.value.push({
role: 'assistant',
content: '你好!我是您的AI志愿填报顾问,请问有什么能帮到您?',
isLoading: false,
visibleChars: 0,
isStreaming: false
});
// 确保欢迎消息完全可见
messages.value[0].visibleChars = messages.value[0].content.length;
scrollToBottom();
// 聚焦输入框
nextTick(() => {
textarea.value.focus();
});
});
// 监听消息变化自动滚动
watch(messages, scrollToBottom, {deep: true});
return {
messages,
userInput,
isLoading,
darkMode,
chatContainer,
textarea,
sendMessage,
stopResponse,
toggleDarkMode,
adjustTextareaHeight,
startNewConversation
};
}
}).mount('#app');
</script>
</body>
</html>
GitHub:3323223659/Java-AI: SpringAI与LangChain4j学习dome
如果有什么疑问或者建议欢迎评论区留言讨论!
1306

被折叠的 条评论
为什么被折叠?



