【LangChain4j 实战】AI志愿填报顾问(RAG、Function Calling)

跟随虾哥项目实践,硬件选小智就对了

xiaozhi 开源方案官方适配,二次开发文档齐全

一、前言

二、实现效果 

三、代码实现

        后端实现

                前置条件

                pom.xml

                application.yml

                ChatController 控制器接口

                ConsultantService 会话接口层

                system.txt

                PDF知识库文件

                CommonConfig 核心组件配置类

                Redis对话存储实现

                Tool 调用工具类

        前端实现

                index.html


一、前言

【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

如果有什么疑问或者建议欢迎评论区留言讨论! 

跟随虾哥项目实践,硬件选小智就对了

xiaozhi 开源方案官方适配,二次开发文档齐全

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值