Vue+SpringBoot+langchain4j实战案例:实现AI消息问答 及 Markdown打字机渲染效果
文章目录
- 前言
- 案例介绍
- 前端实现
- 技术栈
- 实现markdown渲染组件
- MarkdownRenderer.vue(核心markdown渲染组件)
- TypingMarkdownRenderer.vue(封装打字机效果组件)
- ChatWindow.vue:ai回答页面
- 其他页面说明
- 后端实现
- 技术栈
- 初始配置
- pom.xml引入依赖
- application.yaml配置参数
- 1)AiConfig 模型配置类 & EnvironmentContext环境变量
- 2)KnowledgeAgent(知识库agent定义类)
- 3)MyWebMvcConfig(跨域配置类)
- 4)ChatForm(请求实体类)
- 5)KnowledgeChatController(chat接口类)
- 测试
- 前置启动
- 测试前置检索+ai回答场景
- 测试ai简单回答
- 资料获取
前言
博主介绍:✌目前全网粉丝4W+,csdn博客专家、Java领域优质创作者,博客之星、阿里云平台优质作者、专注于Java后端技术领域。
涵盖技术内容:Java后端、大数据、算法、分布式微服务、中间件、前端、运维等。
博主所有博客文件目录索引:博客目录索引(持续更新)
CSDN搜索:长路
视频平台:b站-Coder长路
案例介绍
本章节将会提供SpringBoot+Vue的AI对话界面案例,包含前后端代码,前端使用的是Vue、后端使用的是SpringBoot。
源码如下:
- gitee:https://gitee.com/changluJava/demo-exer/tree/master/ai/demos/springboot-vue-aichat
- github:https://github.com/changluya/Java-Demos/tree/master/ai/demos/springboot-vue-aichat
技术栈:SpringBoot3+jdk17
ai模型:百炼平台的千问
欢迎页:
两种场景分别是:ai正常回答、前置检索效果+ai正常回答,ai回答会有打字机效果
1)前置检索+ai回答
2)ai正常回答
前端实现
下面只是贴了一部分核心代码,完整代码见上面仓库。
技术栈
vue2+vite,引入的依赖如下:
"dependencies": {"axios": "^1.9.0","element-ui": "^2.15.14","github-markdown-css": "^5.8.1","highlight.js": "^11.11.1","markdown-it": "^14.1.0","uuid": "^10.0.0","vue": "^2.7.16","vue-router": "^3.6.5"
},
"devDependencies": {"@vitejs/plugin-vue2": "^2.3.1","cross-env": "^7.0.3","vite": "^5.4.0"
}
实现markdown渲染组件
引入依赖:
npm install highlight.js github-markdown-css markdown-it
MarkdownRenderer.vue(核心markdown渲染组件)
<template><div class="markdown-body"><div v-html="compiledMarkdown" /><span v-if="isTyping" class="typing-cursor"></span></div>
</template><script>
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
import 'github-markdown-css/github-markdown.css'
import 'highlight.js/styles/github.css'export default {props: {content: {type: String,required: true},isTyping: {type: Boolean,default: false}},data() {return {md: new MarkdownIt({html: true,linkify: true,typographer: true,highlight: (str, lang) => {if (lang && hljs.getLanguage(lang)) {try {return `<pre class="hljs"><code>${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`} catch (__) {}}return `<pre class="hljs"><code>${this.md.utils.escapeHtml(str)}</code></pre>`}})}},computed: {compiledMarkdown() {return this.md.render(this.content)}}
}
</script><style>
.markdown-body {box-sizing: border-box;min-width: 200px;max-width: 100%;/* padding: 20px; */background-color: #ffffff;border-radius: 8px;/* box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); */
}.hljs {padding: 1em;border-radius: 6px;font-size: 14px;
}@keyframes blink {50% { opacity: 0; }
}.typing-cursor {display: inline-block;width: 8px;height: 1.2em;background: #333;margin-left: 2px;animation: blink 1s step-end infinite;vertical-align: middle;
}
</style>
TypingMarkdownRenderer.vue(封装打字机效果组件)
<template><div><markdown-renderer:content="displayContent":is-typing="isTyping"/></div>
</template><script>
import MarkdownRenderer from '@/components/MarkdownRenderer.vue'export default {components: { MarkdownRenderer },props: {content: {type: String,default: ''},typingSpeed: {type: Number,default: 20 // 每多少毫秒显示一个字符}},data() {return {displayContent: '',isTyping: false,typingInterval: null,lastContent: '',currentIndex: 0}},watch: {content: {handler(newContent) {this.handleContentChange(newContent)},deep: true}},mounted() {if (this.content) {this.startTyping(this.content)}},beforeDestroy() {this.stopTyping()},methods: {sanitizeMarkdown(content) {// 修复常见的Markdown格式问题return content.replace(/([^`])````/g, '$1```') // 修复多余的反引号.replace(/^#+\s+/gm, '\n$&') // 确保标题前有换行.replace(/(\n```)([^\n])/g, '$1\n$2'); // 确保代码块后有换行},handleContentChange(newContent) {newContent = this.sanitizeMarkdown(newContent);// 如果内容相同,不做处理if (newContent === this.lastContent) return// 如果正在打字,停止当前打字效果if (this.isTyping) {this.stopTyping()}// 检查新内容是否是在旧内容基础上增加的if (newContent.startsWith(this.lastContent)) {// 流式更新:继续在已有内容后打字this.currentIndex = this.lastContent.lengththis.startTyping(newContent, true)} else {// 全新内容:从头开始打字this.currentIndex = 0this.startTyping(newContent)}},startTyping(content, isContinued = false) {this.lastContent = contentthis.isTyping = true// 如果是继续打字,保留当前显示内容if (!isContinued) {this.displayContent = ''this.currentIndex = 0}// 立即显示第一个字符,然后设置定时器if (this.currentIndex === 0 && content.length > 0) {this.displayContent = content[0]this.currentIndex = 1}this.typingInterval = setInterval(() => {if (this.currentIndex < content.length) {this.displayContent = content.substring(0, this.currentIndex + 1)this.currentIndex++} else {this.stopTyping()}}, this.typingSpeed)},stopTyping() {clearInterval(this.typingInterval)this.isTyping = falsethis.$emit('typing-complete')}}
}
</script><style scoped>
/* 可以添加一些组件特定的样式 */
</style>
ChatWindow.vue:ai回答页面
<template><div class="app-layout"><div class="sidebar"><div class="logo-section"><img src="@/assets/logo.png" alt="智能助手" width="40" height="40" /><span class="logo-text">智能助手</span></div><el-button class="new-chat-button" @click="newChat"><i class="fa-solid fa-plus"></i> 新会话</el-button><div class="chat-history"><h3>会话历史</h3><el-scrollbar style="height: 70vh;"><div class="history-item" v-for="(item, index) in chatHistory" :key="index" @click="loadChat(item.id)"><i class="fa-solid fa-comment-dots"></i><span>{{ item.title || '未命名会话' }}</span></div></el-scrollbar></div></div><div class="main-content"><div class="chat-container"><!-- 使用flex容器包装消息列表和输入框,保持它们宽度一致 --><div class="message-input-wrapper" :class="{'input-at-bottom': messages.length > 0}"><!-- 修改后的欢迎界面 --><div class="welcome-container" v-if="showWelcome && messages.length == 0"><div class="welcome-content"><div class="welcome-header"><img src="@/assets/logo.png" alt="智能助手" class="welcome-logo" /><h1 class="welcome-title">我是 智能助手,很高兴见到你!</h1></div><p class="welcome-description">我可以帮你检索语雀、禅道等内容,请把你的问题发给我吧~</p></div></div><div class="message-list" ref="messaggListRef" v-show="messages.length > 0"><divv-for="(message, index) in messages":key="index":class="message.isUser ? 'message user-message' : 'message bot-message'"><!-- 会话图标 --><div v-if="!message.isUser" class="message-avatar"><img src="@/assets/logo.png" alt="数栈知识库小智" class="bot-logo" /></div><div :class="!message.isUser ? 'message-content': 'message-user-content'"><!-- 会话内容 --><div v-if="!message.isUser && message.steps && message.steps.length > 0"><div class="step-container" style="height: auto;font-size: 10px;"><div class="search-thinking-container"><imgsrc="@/assets/images/search.png":class="message.stepsFinished ? 'auto-pulse-img' : 'auto-pulse-img-keyframes'"style="width: 25px; height: 25px;"alt="搜索"/><span :class="message.stepsFinished ? 'thinking-text' : 'thinking-text-keyframes'">{{message.stepsFinished ? '已完成检索' : '正在搜索中...' }}</span></div><!-- active属性:表示当前是第几个步骤 --><el-steps direction="vertical" :active="message.activeStep" :space="90" finish-status="success"><el-stepv-for="(step, index) in message.steps":key="step.type":title="step.title"><template #description><div class="step-content"><!-- 关键词标签容器 --><div class="keyword-tags" v-if="step.keywords && step.keywords.length > 0"><div class="tags-container"><!-- 修改关键字标签部分 --><spanclass="keyword-tag"v-for="(keyword, i) in step.keywords":key="i"@click.stop="handleKeywordClick(keyword.urls)":style="{ cursor: keyword.urls?.length ? 'pointer' : 'default' }"><i class="el-icon-search"></i>{{ keyword.key }}</span></div></div><div class="step-description">{{ step.content }}</div></div></template></el-step></el-steps></div></div><divclass="loading-dots"v-if="!message.isUser && !message.stepsFinished"><span class="dot"></span><span class="dot"></span><span class="dot"></span></div><div v-if="message.isUser" v-html="message.content"></div><!-- AI回答部分 - 替换为TypingMarkdownRenderer --><TypingMarkdownRendererclass="markdown-renderer"v-if="!message.isUser && !message.isThinking && message.content":content="message.content":typing-speed="typingSpeed":should-type="!message.isHistory"@typing-complete="onMessageTypingComplete(index)"/></div></div></div><div class="inputBox"><div class="input-area"><el-inputv-model="inputMessage"placeholder="给数栈小智发送消息"type="textarea":autosize="{ minRows: 2, maxRows: 1000 }"@keydown.native="handleKeyCode($event)"class="custom-no-border"></el-input><div class="send-area"><!-- <el-switchv-model="isThinkingMode"active-text="深度思考"inactive-text=""active-color="#13ce66"inactive-color="#ff4949"class="thinking-switch"/> --><el-button@click="sendMessage":disabled="isSending"type="primary"circleclass="send-btn"size="mini"><i class="el-icon-top"></i></el-button></div></div></div></div></div><!-- 新增底部固定提示 - 移动到main-content内部,确保在右侧区域 --><div class="footer-notice">内容由 AI 生成,请仔细甄别</div></div><!-- 在模板末尾添加右侧模板 --><keyword-drawer:urls="currentUrls":visible="drawerVisible"@update:visible="drawerVisible = $event"/></div>
</template><script setup>
import { onMounted, ref, watch, reactive } from 'vue'
import { v4 as uuidv4 } from 'uuid'
import MarkdownIt from 'markdown-it'
import TypingMarkdownRenderer from '@/components/TypingMarkdownRenderer.vue' // 引入新组件
import KeywordDrawer from '@/components/KeywordDrawer.vue'
import { chatStream } from '@/api/chatApi'// 右侧关键字列表
const drawerVisible = ref(false)
const currentUrls = ref([])// 新增:定义消息缓冲区和当前消息索引
const messageBuffer = ref('') // 用于累积流式数据
const currentBotMessageIndex = ref(-1) // 记录当前机器人消息在messages数组中的索引
const typingSpeed = ref(30) // 打字速度const messaggListRef = ref()
const isSending = ref(false)
const uuid = ref('')
const inputMessage = ref('')
const messages = ref([])
const chatHistory = ref([])
const md = ref(new MarkdownIt())const isThinkingMode = ref(false);// 思考
const thinkBuffer = ref(''); // 累积思考内容
const isCollectingThink = ref(false); // 是否在收集思考内容
const hasThinkContent = ref(false); // 当前消息是否包含思考内容const showWelcome = ref(true)onMounted(() => {initUUID() // 初始化UUIDloadChatHistory()
// 修改watch部分watch(messages,(newVal) => {showWelcome.value = newVal.length === 0},{ immediate: true })// 注释掉原有的hello()调用,避免自动发送消息// hello() // 保留初次页面渲染时的chat接口调用// console.log("import.meta.env.VITE_API_URL=>", import.meta.env.VITE_API_URL)
})// 新增:防抖函数
const debounce = (func, wait) => {let timeoutreturn function(...args) {clearTimeout(timeout)timeout = setTimeout(() => {func.apply(this, args)}, wait)}
}// 修改后的scrollToBottom函数
const scrollToBottom = debounce(() => {if (messaggListRef.value) {// 使用平滑滚动messaggListRef.value.scrollTo({top: messaggListRef.value.scrollHeight,behavior: 'smooth'})}
}, 100) // 100ms防抖延迟const hello = () => {// sendRequest('你好,检索语雀、知识库,禅道')// sendRequest('你好')
}const sendMessage = () => {if (inputMessage.value.trim()) {sendRequest(inputMessage.value.trim())inputMessage.value = ''}
}const sendRequest = (message) => {// 重置思考相关状态thinkBuffer.value = '';isCollectingThink.value = false;hasThinkContent.value = false;isSending.value = truemessageBuffer.value = ''currentBotMessageIndex.value = messages.value.lengthconst userMsg = {id: Date.now(),isUser: true,content: message,isTyping: false,isThinking: false,}messages.value.push(userMsg)// 修改:初始化机器人消息时不预设任何步骤const botMsg = {id: Date.now() + 1,isUser: false,content: '', fullContent: '',steps: [], // 初始为空数组,根据实际返回数据动态添加activeStep: 0,isTyping: true,stepsFinished: false,isThinking: true}messages.value.push(botMsg)scrollToBottom()// 聊天chatStream(uuid.value,message,isThinkingMode.value ? 1 : 0,(e) => {const fullText = e.event.target.responseTextconsole.log("fullText=>", fullText)let newText = fullText.substring(messages.value.at(-1).fullContent.length)const lines = newText.split('\n');// 累积更新内容,减少DOM操作let accumulatedContent = ''lines.forEach(line => {if (!line.trim()) return;let isNotHasLine = !line.includes('|');let [stepType, contentType, ...contentArr] = line.split('|');stepType = removeDataPrefix(stepType);let content = contentArr.join('|');// if (content.trim() === '```' || content.trim() === '```markdown' || content.trim() === 'markdown'){// return;// }if (content.trim() === '```markdown' || content.trim() === 'markdown'){return;}const currentBotMsg = messages.value.at(-1);// 修改:动态处理步骤类型if (stepType === 'knowledge' || stepType === 'zentao') {// 检查是否已存在该步骤let step = currentBotMsg.steps.find(s => s.type === stepType);if (!step) {// 如果步骤不存在,则创建新步骤step = {title: stepType === 'knowledge' ? '检索语雀知识库' : '检索禅道',content: '检索中...',type: stepType,keywords: []};currentBotMsg.steps.push(step);}// 结束条件if (content.endsWith("end")) {currentBotMsg.activeStep += 1;// 检查是否所有步骤都已完成const hasKnowledge = currentBotMsg.steps.some(s => s.type === 'knowledge');const hasZentao = currentBotMsg.steps.some(s => s.type === 'zentao');// 修改:只有当所有存在的步骤都完成时才标记为完成const knowledgeFinished = !hasKnowledge || currentBotMsg.steps.find(s => s.type === 'knowledge').content !== '检索中...';const zentaoFinished = !hasZentao || currentBotMsg.steps.find(s => s.type === 'zentao').content !== '检索中...';currentBotMsg.stepsFinished = knowledgeFinished && zentaoFinished;currentBotMsg.isThinking = !currentBotMsg.stepsFinished;} else {updateStep(currentBotMsg, stepType, content);}}else if (stepType === 'final') {// 第一次进入时的严格条件检测if (!isCollectingThink.value &&(content.includes("<") ||content.includes("<t") ||content.includes("<th"))) {console.log("enter think...")thinkBuffer.value += content;isCollectingThink.value = true;hasThinkContent.value = true;currentBotMsg.isTyping = true;return;}// 正在收集思考内容if (isCollectingThink.value) {thinkBuffer.value += content;console.log("thinkBuffer.value=>", thinkBuffer.value)// 检测到完整<think>标签时开始转换if (thinkBuffer.value.includes("<think>")) {console.log("正式开始转换=》", thinkBuffer.value)// 移除<think>标签并转换为Markdown引用const processedContent = thinkBuffer.value.replace("<think>", "").replace("</think>", "").split('\n\n').map(line => `> ${line}`).join('\n');// 追加到正式内容currentBotMsg.content = processedContent;console.log("转换后=》", currentBotMsg.content)// 检测是否结束思考块if (thinkBuffer.value.includes("</think>")) {isCollectingThink.value = false;thinkBuffer.value = '';}// 只在内容变化较大时才触发滚动// 累积内容而不是立即更新accumulatedContent += line + '\n'if (accumulatedContent.length > 100) {scrollToBottom()}return;}}console.log("结束思考模式阶段")// 普通AI回复内容currentBotMsg.content += content;// 可能思考模式先触发了,所以这里需要check下if (!currentBotMsg.isThinking) {currentBotMsg.isTyping = true;}// 修改:如果没有其他步骤,直接标记为完成if (currentBotMsg.steps.length === 0) {currentBotMsg.stepsFinished = true;currentBotMsg.isThinking = false;}}// 处理data: 场景 需要换行if (currentBotMsg.stepsFinished && isNotHasLine) {const handleLine = removeDataPrefix(line);// 是否在收集思考if (isCollectingThink.value) {console.log("思考中出现换行场景...")thinkBuffer.value += "\n\n" + handleLine;}else {currentBotMsg.content += "\n\n" + handleLine;}currentBotMsg.isTyping = true;}// 累积内容而不是立即更新accumulatedContent += line + '\n'})messages.value.at(-1).fullContent += newText;// 只在内容变化较大时才触发滚动if (accumulatedContent.length > 100) {scrollToBottom()}},).then(() => {messages.value.at(-1).isTyping = false;isSending.value = false;saveChatHistory();}).catch((error) => {if (error.code === 'ECONNABORTED') {messages.value.at(-1).content = '请求超时,请尝试重新发送';}console.error('流式错误:', error);messages.value.at(-1).isTyping = false;messages.value.at(-1).isThinking = false;isSending.value = false;});}// 更新步骤状态
const updateStep = (message, stepType, content, status) => {const step = message.steps.find(s => s.type === stepType);if (step) {console.log("updateStep => content:", content)// 处理matchKeywords格式if (content.includes('matchKeyAndUrls=>')) {try {const jsonStr = content.replace('matchKeyAndUrls=>', '').trim();const matchData = JSON.parse(jsonStr);// 示范:{"keyword":"生命周期","urls":[{"title":"xxx","description":"xxx","url":"xxx"},{"title":"xxx","description":"xxx","url":"xxx"}]}step.keywords.push({key: matchData.keyword,urls: matchData.urls,clickable: true // 标记为可点击})console.log("type:", stepType, ", keywords:", step.keywords)} catch (e) {console.error('解析matchKeywords失败:', e);}} else {step.content = content;}}
}function removeDataPrefix(str) {if (str.startsWith("data:")) {return str.slice(5); // 从第6个字符开始截取字符串(索引为5)}return str; // 如果不以"data:"开头,直接返回原字符串
}// 打字完成回调
const onMessageTypingComplete = (index) => {messages.value[index].isTyping = false
}// 修改后的initUUID函数
const initUUID = () => {// 生成新的UUIDuuid.value = uuidToNumber(uuidv4())localStorage.setItem('user_uuid', uuid.value)// 清空当前会话消息messages.value = []
}const uuidToNumber = (uuid) => {let number = 0for (let i = 0; i < uuid.length && i < 6; i++) {const hexValue = uuid[i]number = number * 16 + (parseInt(hexValue, 16) || 0)}return number % 1000000
}// 修改后的newChat函数
const newChat = () => {// 生成新的UUID并初始化initUUID()// 清空消息并显示欢迎界面messages.value = []showWelcome.value = true// 深入思考回退isThinkingMode.value = falseconsole.log('newChat think: ', isThinkingMode)// 更新会话历史saveChatHistory()
}// 会话历史管理
const loadChatHistory = () => {const history = localStorage.getItem('chat_history')if (history) {chatHistory.value = JSON.parse(history)}
}const saveChatHistory = () => {if (messages.value.length === 0) return// 获取当前会话IDconst currentChatId = uuid.value// 从消息中提取标题(前30个字符)const title = messages.value[0]?.content?.substring(0, 30) || '新会话'// 检查是否已存在该会话const existingIndex = chatHistory.value.findIndex(item => item.id === currentChatId)if (existingIndex !== -1) {// 更新现有会话chatHistory.value[existingIndex] = {id: currentChatId,title,lastUpdated: new Date().toISOString(),messages: messages.value}} else {// 添加新会话chatHistory.value.unshift({id: currentChatId,title,lastUpdated: new Date().toISOString(),messages: messages.value})}// 限制历史记录数量if (chatHistory.value.length > 20) {chatHistory.value = chatHistory.value.slice(0, 20)}// 保存到本地存储localStorage.setItem('chat_history', JSON.stringify(chatHistory.value))
}// 修改loadChat函数
const loadChat = (chatId) => {const chat = chatHistory.value.find(item => item.id === chatId)if (chat) {// 为历史消息添加isHistory标志messages.value = chat.messages.map(msg => ({...msg,isHistory: true}))uuid.value = chatIdscrollToBottom()// 加载历史聊天时隐藏欢迎界面showWelcome.value = false}
}// 键盘回车事件
const handleKeyCode = (event) => {console.log("event=>", event)if (event.keyCode == 13) {if (!event.metaKey) {event.preventDefault();sendMessage();} else {this.messageTxt = this.messageTxt + '\n';}}
}// 关键字点击
const handleKeywordClick = (urls) => {console.log('点击关键词时的 urls:', urls);if (urls && urls.length > 0) {const validUrls = urls.filter(url => url.title && url.description && url.url).map(url => ({...url,date: url.date // Ensure date is passed through}));if (validUrls.length > 0) {currentUrls.value = validUrls;drawerVisible.value = true;} else {console.error('传递的 urls 数组中没有有效的链接数据');}}
}</script><style scoped>
/* 全局样式 */
* {box-sizing: border-box;
}body {font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}.app-layout {display: flex;height: 100vh;overflow: hidden;
}/* 侧边栏样式 */
.sidebar {width: 300px;background-color: #f8f9fa;border-right: 1px solid #e9ecef;display: flex;flex-direction: column;overflow: hidden;
}.welcome-container {display: flex;justify-content: center;align-items: center;width: 100%;min-height: 15vh;margin-top: -180px;
}.welcome-content {display: flex;flex-direction: column;align-items: center;max-width: 800px;text-align: center;padding: 20px;
}.welcome-header {display: flex;align-items: center;margin-bottom: 16px;
}.welcome-logo {width: 60px;height: 60px;border-radius: 50%;object-fit: cover;margin-right: 20px;
}.welcome-title {font-size: 28px;font-weight: 600;color: #333;line-height: 1.4;margin: 0;
}.welcome-description {font-size: 16px;color: #666;line-height: 1.6;max-width: 600px;margin-top: 8px;
}.logo-section {display: flex;align-items: center;padding: 20px;border-bottom: 1px solid #e9ecef;
}.logo-section img {width: 40px;height: 40px;margin-right: 10px;
}.logo-text {font-size: 18px;font-weight: 600;color: #333;
}.new-chat-button {margin: 15px;
}.chat-history {flex: 1;padding: 10px;overflow: hidden;
}.chat-history h3 {font-size: 14px;color: #6c757d;margin: 10px 0 5px 5px;
}.history-item {display: flex;align-items: center;padding: 8px 10px;border-radius: 4px;cursor: pointer;transition: background-color 0.2s;font-size: 14px;color: #333;
}.history-item:hover {background-color: #e9ecef;
}.history-item i {margin-right: 10px;color: #6c757d;
}/* 主内容区样式 */
.main-content {flex: 1;display: flex;flex-direction: column;background-color: #ffffff;position: relative; /* 新增:为主内容区添加相对定位 */
}.chat-container {display: flex;flex-direction: column;height: 100%;padding: 0px 30px;
}/* 修改后的底部提示样式 - 现在位于右侧内容区底部 */
.footer-notice {position: absolute;bottom: 10px;left: 0;width: 100%;text-align: center;color: #888;font-size: 14px;padding: 8px 0;z-index: 100;background-color: rgba(255, 255, 255, 0.8);
}/* 新增:包装消息列表和输入框的容器 */
.message-input-wrapper {display: flex;flex-direction: column;align-items: center;width: 100%;min-height: 100%;
}.message-input-wrapper:not(.input-at-bottom) {justify-content: center;
}.message-input-wrapper.input-at-bottom {justify-content: space-between;
}.message-list {flex: 1;overflow-y: auto;padding: 10px;margin-bottom: 15px;border-radius: 8px;background-color: #ffffff;/* 使消息列表宽度与输入框一致 */width: 65%;
}.message {display: flex;margin-bottom: 15px;margin-top: 30px
}.message-avatar {width: 36px;height: 36px;border-radius: 50%;display: flex;align-items: center;justify-content: center;font-size: 18px;flex-shrink: 0;overflow: hidden; /* 确保图片不会超出容器 */
}.bot-logo {width: 100%;height: 100%;object-fit: cover; /* 使图片适应容器 */
}.user-message {align-self: flex-end;flex-direction: row-reverse;
}.user-message .message-avatar {background-color: #007bff;color: white;margin-left: 10px;
}.bot-message {align-self: flex-start;
}.bot-message .message-avatar {margin-right: 10px;
}.message-user-content {background-color: #f0f6fe;padding: 16px 16px;border-radius: 8px;box-shadow: 0 1px 3px rgba(0,0,0,0.1);line-height: 1.6;max-width: 100%;
}.step-container {border-radius: 12px;line-height: 1.6;border: .5px solid rgba(0, 0, 0, .13);max-width: 100%;padding: 16px;/* 新增固定宽度和溢出处理 */width: 600px; /* 或设置具体像素值如 width: 500px; */max-width: 100%;overflow-x: auto; /* 水平溢出时显示滚动条 */word-break: break-word; /* 允许单词内换行 */
}.message-content {background-color: white;padding: 0px 16px 16px 16px;border-radius: 8px;line-height: 1.6;max-width: 100%;width: 600px;
}.user-message .message-content {background-color: #e7f5ff;
}.loading-dots {display: flex;margin-top: 18px;
}.dot {width: 6px;height: 6px;background-color: #6c757d;border-radius: 50%;margin-right: 4px;animation: pulse 1.2s infinite ease-in-out both;
}.dot:nth-child(2) {animation-delay: -0.4s;
}.dot:nth-child(3) {animation-delay: -0.2s;
}@keyframes pulse {0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }40% { transform: scale(1); opacity: 1; }
}.inputBox {display: flex;align-items: center;flex-direction: column;width: 100%;margin-bottom: 50px; /* 为底部提示留出空间 */
}/* 输入区域样式 */
.input-area {display: flex;flex-direction: column;border-radius: 8px;background-color: rgb(243, 244, 246);box-shadow: 0 1px 3px rgba(0,0,0,0.1);padding: 15px 10px 15px 10px;width: 65%;border-radius: 24px;margin-bottom: 15px;
}.send-area {display: flex;justify-content: flex-end; /* 改为右对齐 */align-items: center;margin-top: 10px;padding: 0 10px;
}.thinking-switch {margin: 0; /* 移除默认margin */
}.send-btn {margin: 0;padding: 0;width: 40px;height: 40px;
}/* 调整图标大小和位置 */
.send-btn i {font-size: 18px;
}/* 关键词标签样式 */
.step-content {padding: 5px 0;
}.step-description {margin-top: 10px;margin-bottom: 8px;color: #555;
}.keyword-tags {margin-top: 8px;
}.tag-label {font-size: 12px;color: #6c757d;margin-right: 5px;
}.tags-container {display: flex;flex-wrap: wrap;gap: 4px;margin-top: 4px;
}.keyword-tag {padding: 3px 8px;background-color: #f0f6fe;color: #007bffcc;border-radius: 4px;font-size: 12px;white-space: nowrap;
}.markdown-renderer{margin-top: 10px;
}/* 容器:让图标和文字水平对齐 */
.search-thinking-container {display: flex;align-items: center; /* 垂直居中 */gap: 10px; /* 图标和文字之间的间距 */margin-bottom: 10px;
}/* search图标 缩小放大效果 */
.auto-pulse-img-keyframes {width: 25px;height: 25px;margin-bottom: 10px;animation: fast-search-pulse 1.2s infinite;
}
.auto-pulse-img {width: 25px;height: 25px;margin-bottom: 10px;
}/* 文字动画同步为0.6秒周期 */
.thinking-text-keyframes {font-size: 16px;font-weight: 500;color: #333;animation: text-blink 1.2s infinite ease-in-out;
}
.thinking-text {font-size: 16px;font-weight: 500;color: #333;
}.el-switch {margin-left: 10px;
}.el-switch__label {color: #606266;font-size: 14px;
}.el-switch__label.is-active {color: #13ce66;
}/* 确保引用块样式清晰 */
.markdown-renderer blockquote {border-left: 3px solid #d1d5db;padding: 0.5rem 1rem;margin: 0.75rem 0;background-color: #f9fafb;color: #4b5563;
}/* 空行保持最小高度 */
.markdown-renderer blockquote p:empty::after {content: " ";display: inline-block;
}.keyword-tag {padding: 3px 8px;background-color: #f0f6fe;color: #007bffcc;border-radius: 4px;font-size: 12px;white-space: nowrap;transition: all 0.2s;
}.keyword-tag:hover {background-color: #e0ecff;color: #007bff;
}/* 新增:用户消息内容样式,确保换行正常 */
.user-message-content {background-color: #f0f6fe;padding: 16px 16px;border-radius: 8px;box-shadow: 0 1px 3px rgba(0,0,0,0.1);line-height: 1.6;max-width: 100%;word-break: break-word; /* 强制换行 */
}::v-deep .custom-no-border .el-textarea__inner {/* 移除边框 */border: none;box-shadow: none;background-color: rgb(243, 244, 246);/* 移除调整大小控制柄 */resize: none;max-height: 450px;word-break: break-all; /* 允许单词内换行 */
}@keyframes text-blink {0%, 100% { opacity: 0.7; }50% { opacity: 1; }
}@keyframes fast-search-pulse {0%, 100% {transform: scale(1);}20% {transform: scale(1.3);}40% {transform: scale(0.9);}60% {transform: scale(1.2);}80% {transform: scale(1);}
}/* 响应式设计 - 优化版 */
@media (max-width: 1200px) {.message-list, .input-area {width: 75%;}.step-container {width: 100%;}
}@media (max-width: 992px) {.message-list, .input-area {width: 85%;}.step-container {width: 100%;}
}@media (max-width: 768px) {.welcome-header {flex-direction: column;text-align: center;}.welcome-logo {margin-right: 0;margin-bottom: 15px;}.welcome-title {font-size: 22px;}.welcome-description {font-size: 14px;}.app-layout {flex-direction: column;}.sidebar {width: 100%;height: auto;flex-direction: row;flex-wrap: wrap;padding: 10px;}.logo-section {flex: 1;padding: 0;border-bottom: none;}.new-chat-button {width: auto;margin: 0 10px;}.chat-history {display: none;}.main-content {padding: 0;}.chat-container {padding: 10px;}.message-list, .input-area {width: 95%;}.send-btn {width: 36px;height: 36px;}.send-btn i {font-size: 16px;}.message {max-width: 95%;}.ai-response {margin-left: 0;}.footer-notice {position: fixed;bottom: 5px;left: 0;width: 100%;font-size: 12px;background-color: rgba(255, 255, 255, 0.95);}.inputBox {margin-bottom: 30px;}.step-container, .message-content {width: 100%; /* 小屏幕下占满宽度 */}
}/* 超小屏幕优化 */
@media (max-width: 576px) {.welcome-logo {width: 50px;height: 50px;}.welcome-title {font-size: 20px;}.welcome-description {font-size: 13px;}.message-list, .input-area {width: 100%;}.step-container, .message-content, .message-user-content {padding: 10px;}.markdown-renderer {font-size: 14px;}.keyword-tag {padding: 2px 6px;font-size: 11px;}.footer-notice {font-size: 12px;bottom: 5px;}.inputBox {margin-bottom: 30px;}.step-container {width: 100%;}
}
</style>
其他页面说明
后端实现
技术栈
springboot3+jdk17+langchain4j
初始配置
pom.xml引入依赖
<properties><java.version>17</java.version><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding><project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding><spring-boot.version>3.2.6</spring-boot.version><langchain4j.version>1.0.0-beta4</langchain4j.version>
</properties>
<dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!-- 前后端分离中的后端接口测试工具 --><dependency><groupId>com.github.xiaoymin</groupId><artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId><version>4.3.0</version></dependency><!--langchain4j高级功能--><dependency><groupId>dev.langchain4j</groupId><artifactId>langchain4j-spring-boot-starter</artifactId></dependency><dependency><groupId>dev.langchain4j</groupId><artifactId>langchain4j-core</artifactId></dependency><dependency><groupId>dev.langchain4j</groupId><artifactId>langchain4j-ollama</artifactId></dependency><dependency><groupId>dev.langchain4j</groupId><artifactId>langchain4j-community-dashscope</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId></dependency><!-- rag --><dependency><groupId>dev.langchain4j</groupId><artifactId>langchain4j-easy-rag</artifactId></dependency><!--流式输出--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webflux</artifactId></dependency><dependency><groupId>dev.langchain4j</groupId><artifactId>langchain4j-reactor</artifactId></dependency>
</dependencies>
<dependencyManagement><dependencies><!--引入SpringBoot依赖管理清单--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-dependencies</artifactId><version>${spring-boot.version}</version><type>pom</type><scope>import</scope></dependency><!--引入langchain4j依赖管理清单--><dependency><groupId>dev.langchain4j</groupId><artifactId>langchain4j-bom</artifactId><version>${langchain4j.version}</version><type>pom</type><scope>import</scope></dependency><!--引入百炼依赖管理清单--><dependency><groupId>dev.langchain4j</groupId><artifactId>langchain4j-community-bom</artifactId><version>${langchain4j.version}</version><type>pom</type><scope>import</scope></dependency></dependencies>
</dependencyManagement>
application.yaml配置参数
server:port: 8999# 百炼平台
langchain4j:community:dashscope:chat-model:api-key: ${DASH_SCOPE_API_KEY}
# model-name: qwen-plus-latestmodel-name: qwen-plus
# model-name: qwen-turbo-1101open-ai:chat-model:# ==硅基流动==base-url: https://api.siliconflow.cn/v1api-key: ${GJLD_API_KEY}# 免费
# model-name: deepseek-ai/DeepSeek-R1-0528-Qwen3-8B
# model-name: Qwen/Qwen3-8B# 智谱ai,速度还不错,比上面两个快 【Tools、推理模型】model-name: THUDM/GLM-Z1-9B-0414
# model-name: Qwen/Qwen2.5-7B-Instruct# 付费
# model-name: deepseek-ai/DeepSeek-R1# ==kimi==
# base-url: https://api.moonshot.cn/v1
# api-key: ${MOONSHOT_API_KEY}
# model-name: moonshot-v1-8k
# model-name: kimi-thinking-preview
1)AiConfig 模型配置类 & EnvironmentContext环境变量
AiConfig.java:
@Configuration
public class AiConfig {// dashscope@Value("${langchain4j.community.dashscope.chat-model.api-key}")private String dashScopeApiKey;@Value("${langchain4j.community.dashscope.chat-model.model-name}")private String dashScopeModelName;@Beanpublic ChatModel chatModel() {ChatModel chatModel = QwenChatModel.builder().apiKey(dashScopeApiKey).modelName(dashScopeModelName).build();return chatModel;}@Beanpublic StreamingChatModel streamingChatModel() {// ollama
// OllamaStreamingChatModel ollamaStreamingChatModel = OllamaStreamingChatModel.builder()
// .baseUrl(ollamaUrl)
// .modelName(streamModelName)
// .logRequests(true)
// .logResponses(true)
// .build();
// return ollamaStreamingChatModel;// dashscopeQwenStreamingChatModel qwenStreamingChatModel = QwenStreamingChatModel.builder().apiKey(dashScopeApiKey).modelName(dashScopeModelName).build();return qwenStreamingChatModel;}
}
Environment.java:
@Component
@Data
public class EnvironmentContext {@Autowiredprivate Environment environment;
}
2)KnowledgeAgent(知识库agent定义类)
@AiService(wiringMode = AiServiceWiringMode.EXPLICIT,
// chatModel = "qwenChatModel",streamingChatModel = "streamingChatModel"
)
public interface KnowledgeAgent {// 流式返回Flux<String> chat(@UserMessage String userMessage);}
3)MyWebMvcConfig(跨域配置类)
@Configuration
public class MyWebMvcConfig implements WebMvcConfigurer {/*** 解决跨域问题*/@Overridepublic void addCorsMappings(CorsRegistry registry) {registry.addMapping("/**") //指定的映射地址.allowedHeaders("*") //允许携带的请求头.allowedMethods("*") //允许的请求方法.allowedOrigins("*"); //添加跨域请求头 Access-Control-Allow-Origin,值如:"https://domain1.com"或"*"}}
4)ChatForm(请求实体类)
@Data
public class ChatForm {private String message;//用户问题
}
5)KnowledgeChatController(chat接口类)
@Tag(name = "知识库agent")
@RestController
@RequestMapping("/knowledge")
public class KnowledgeChatController {@Autowiredprivate KnowledgeAgent knowledgeAgent;@Autowiredprivate StreamingChatModel streamingChatModel;@Autowiredprivate ChatModel chatModel;@Operation(summary = "对话")@GetMapping(value = "/chat")public String chat(@RequestParam("msg")String msg) {return chatModel.chat(msg);}@Operation(summary = "对话")@PostMapping(value = "/chatAgent", produces = "text/stream;charset=utf-8")public Flux<String> chat(@RequestBody ChatForm chatForm) {return knowledgeAgent.chat(chatForm.getMessage());}@Operation(summary = "流式对话(含思考过程)")@PostMapping(value = "/chatStream", produces = "text/event-stream;charset=utf-8") // sse标准格式public Flux<String> chatStream(@RequestBody ChatForm chatForm) {return Flux.<String>create(emitter -> { // Explicit type parameter
// sleep(1500);
// // 阶段1:知识库检索
// String knowledgeResult = "检索到知识库文档《XXX系统使用手册》中关于XX功能的说明:...";
// emitter.next("knowledge|CONTENT|" + knowledgeResult);
// sleep(1500);
// emitter.next("knowledge|CONTENT|==> end");
// sleep(1500);
//
// // 阶段2:禅道检索
// String zentaoResult = "禅道系统中未发现与当前问题相关的历史Bug记录";
// emitter.next("zentao|CONTENT|" + zentaoResult);
// sleep(1500);
// emitter.next("zentao|CONTENT|==> end");
// sleep(1500);
//
// // 阶段3:思考过程
// String analysisResult = "综合知识库信息,需要从技术实现、业务逻辑、用户场景三个维度进行解答...\n\n";
// emitter.next("thinking|CONTENT|" + analysisResult);// 阶段4:大模型流式回答streamingChatModel.chat(chatForm.getMessage(), new StreamingChatResponseHandler() {@Overridepublic void onPartialResponse(String partialResponse) {emitter.next("final|CONTENT|" + partialResponse);}@Overridepublic void onCompleteResponse(ChatResponse completeResponse) {emitter.complete();}@Overridepublic void onError(Throwable error) {emitter.error(error);}});}).subscribeOn(Schedulers.boundedElastic());}// 模拟延时的工具方法(非阻塞当前线程)private void sleep(long millis) {try {Thread.sleep(millis);} catch (InterruptedException e) {Thread.currentThread().interrupt();}}}
测试
前置启动
配置百炼平台的apiKey:配置在环境变量DASH_SCOPE_API_KEY中
vim ~/.zshrc# 配置内容
export DASH_SCOPE_API_KEY="xxxxxxxxx"# 生效配置文件
source ~/.zshrc
前端启动服务:
npm installnpm run dev
后端启动服务:启动SpringBoot启动器
成功运行如下:
测试前置检索+ai回答场景
首先我们先将下面这块代码放开:
然后界面上输入问题:注意这块效果实际上是我们代码中去模拟,你可以传输你自定义的协议信息,然后让前端进行渲染,后面ai回答再单独特定指定协议。
测试ai简单回答
将这部分代码进行注释:
此时重新启动服务,效果如下:
资料获取
大家点赞、收藏、关注、评论啦~
精彩专栏推荐订阅:在下方专栏👇🏻
- 长路-文章目录汇总(算法、后端Java、前端、运维技术导航):博主所有博客导航索引汇总
- 开源项目Studio-Vue—校园工作室管理系统(含前后台,SpringBoot+Vue):博主个人独立项目,包含详细部署上线视频,已开源
- 学习与生活-专栏:可以了解博主的学习历程
- 算法专栏:算法收录
更多博客与资料可查看👇🏻获取联系方式👇🏻,🍅文末获取开发资源及更多资源博客获取🍅
整理者:长路 时间:2025.8.3