前言
博主介绍:✌目前全网粉丝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.length
this.startTyping(newContent, true)
} else {
// 全新内容:从头开始打字
this.currentIndex = 0
this.startTyping(newContent)
}
},
startTyping(content, isContinued = false) {
this.lastContent = content
this.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 = false
this.$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">
<div
v-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">
<img
src="@/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-step
v-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">
<!-- 修改关键字标签部分 -->
<span
class="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>
<div
class="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 -->
<TypingMarkdownRenderer
class="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-input
v-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-switch
v-model="isThinkingMode"
active-text="深度思考"
inactive-text=""
active-color="#13ce66"
inactive-color="#ff4949"
class="thinking-switch"
/> -->
<el-button
@click="sendMessage"
:disabled="isSending"
type="primary"
circle
class="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() // 初始化UUID
loadChatHistory()
// 修改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 timeout
return 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 = true
messageBuffer.value = ''
currentBotMessageIndex.value = messages.value.length
const 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.responseText
console.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 = () => {
// 生成新的UUID
uuid.value = uuidToNumber(uuidv4())
localStorage.setItem('user_uuid', uuid.value)
// 清空当前会话消息
messages.value = []
}
const uuidToNumber = (uuid) => {
let number = 0
for (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 = false
console.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
// 获取当前会话ID
const 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 = chatId
scrollToBottom()
// 加载历史聊天时隐藏欢迎界面
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-latest
model-name: qwen-plus
# model-name: qwen-turbo-1101
open-ai:
chat-model:
# ==硅基流动==
base-url: https://api.siliconflow.cn/v1
api-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;
@Bean
public ChatModel chatModel() {
ChatModel chatModel = QwenChatModel.builder()
.apiKey(dashScopeApiKey)
.modelName(dashScopeModelName)
.build();
return chatModel;
}
@Bean
public StreamingChatModel streamingChatModel() {
// ollama
// OllamaStreamingChatModel ollamaStreamingChatModel = OllamaStreamingChatModel.builder()
// .baseUrl(ollamaUrl)
// .modelName(streamModelName)
// .logRequests(true)
// .logResponses(true)
// .build();
// return ollamaStreamingChatModel;
// dashscope
QwenStreamingChatModel qwenStreamingChatModel = QwenStreamingChatModel.builder()
.apiKey(dashScopeApiKey)
.modelName(dashScopeModelName)
.build();
return qwenStreamingChatModel;
}
}
Environment.java:
@Component
@Data
public class EnvironmentContext {
@Autowired
private 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 {
/**
* 解决跨域问题
*/
@Override
public 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 {
@Autowired
private KnowledgeAgent knowledgeAgent;
@Autowired
private StreamingChatModel streamingChatModel;
@Autowired
private 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() {
@Override
public void onPartialResponse(String partialResponse) {
emitter.next("final|CONTENT|" + partialResponse);
}
@Override
public void onCompleteResponse(ChatResponse completeResponse) {
emitter.complete();
}
@Override
public 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 install
npm run dev
后端启动服务:启动SpringBoot启动器
成功运行如下:
测试前置检索+ai回答场景
首先我们先将下面这块代码放开:
然后界面上输入问题:注意这块效果实际上是我们代码中去模拟,你可以传输你自定义的协议信息,然后让前端进行渲染,后面ai回答再单独特定指定协议。
测试ai简单回答
将这部分代码进行注释:
此时重新启动服务,效果如下:
资料获取
大家点赞、收藏、关注、评论啦~
精彩专栏推荐订阅:在下方专栏👇🏻
- 长路-文章目录汇总(算法、后端Java、前端、运维技术导航):博主所有博客导航索引汇总
- 开源项目Studio-Vue—校园工作室管理系统(含前后台,SpringBoot+Vue):博主个人独立项目,包含详细部署上线视频,已开源
- 学习与生活-专栏:可以了解博主的学习历程
- 算法专栏:算法收录
更多博客与资料可查看👇🏻获取联系方式👇🏻,🍅文末获取开发资源及更多资源博客获取🍅
整理者:长路 时间:2025.8.3