目录
1.创建账号,获取ai调用参数
考虑到本地化部署deepseek电脑带不动且对话响应速度慢,所以选择第三方平台,我选的是阿里云百炼大模型平台,它对新账号赠送有免费的token额度。足够自己学习用了。
创建账号登录后进入大模型页面创建api-key
点击查看复制API key。
再进入模型广场点击想要使用的模型,选择API参考,复制模型名称和基础URL。
2.java后端
注意Spring AI要求Springboot3高版本,jdk17
(1)加入依赖
我这里使用的若依框架,jdk用的17,Springboot3.3,官方文档入门 :: Spring AI 中文文档 (springdoc.cn)
在ruoyi根目录pom文件加上SpringAI的依赖管理
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
在ruoyi-admin的pom文件加上springai相关依赖,另外我还加了lombok
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency> //本地化部署需要加这个,不用本地化部署就不加
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
</dependency>
(2)配置参数
在application文件配置模型参数(api-key,base-url,moel模型名称),注意这里我base-url去掉了/v1,加上/v1就报错,我不知道啥原因
再配置日志springAI的日志打印级别
(3)写配置类
创建CommonConfiguration类,实现会话记忆,
new InMemoryChatMemoryRepository(); // 注意这里使用默认的内存存储,可以使用自己定义的实现,如 RedisChatMemoryRepository、JdbcChatMemoryRepository 等,做持久化处理,需要实现ChatMemoryRepository 接口方法
package com.ruoyi.web.controller.ai;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CommonConfiguration {
@Bean
public ChatMemoryRepository chatMemoryRepository() {
return new InMemoryChatMemoryRepository();
// 注意这里使用默认的内存存储,可以使用自己定义的实现,如 RedisChatMemoryRepository、JdbcChatMemoryRepository 等,做持久化处理,需要实现ChatMemoryRepository 接口方法
}
@Bean
public ChatMemory chatMemory() {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(chatMemoryRepository())
.maxMessages(10)
.build();
}
@Bean
public ChatClient chatClient(OpenAiChatModel model){
return ChatClient.builder(model)
.defaultSystem("你是代码萌新知博客的小助手,请你以该身份温柔的回答问题!")
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory()).build(),new SimpleLoggerAdvisor())
.build();
}
}
(4)AI对话接口
保存数据需要创建对话详情表,可参考以下字段。 private Long id; private Long sessionId; private String role; private String content;
在调用前存入用户对话数据。调用完成后存入ai响应数据。
package com.ruoyi.web.controller.ai;
import com.ruoyi.ai.domain.ChatSessionDetail;
import com.ruoyi.ai.service.IChatSessionDetailService;
import com.ruoyi.common.annotation.Anonymous;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.util.Date;
@RequiredArgsConstructor
@RestController
@RequestMapping("/ai")
class ChatController {
@Autowired
private IChatSessionDetailService chatSessionDetailService;
private final ChatClient chatClient;
@Anonymous
@RequestMapping("chat")
public Flux<String> chat(@RequestParam String prompt, @RequestParam Long chatId){
// 保存该论对话的用户消息
ChatSessionDetail userMsg = new ChatSessionDetail ();
userMsg.setSessionId(chatId);
userMsg.setRole("user");
userMsg.setContent(prompt);
userMsg.setCreateTime(new Date());
chatSessionDetailService.insertChatSessionDetail(userMsg);
StringBuilder responseBuffer = new StringBuilder();
return chatClient.prompt().user(prompt)
.advisors(advisorSpec -> advisorSpec.param(ChatMemory.CONVERSATION_ID, chatId))
.stream().content().doOnNext(responseBuffer::append).doFinally(e -> {
// 保存AI响应消息
ChatSessionDetail aiMsg = new ChatSessionDetail();
aiMsg.setSessionId(chatId);
aiMsg.setRole("assistant");
aiMsg.setContent(responseBuffer.toString());
aiMsg.setCreateTime(new Date());
chatSessionDetailService.insertChatSessionDetail(aiMsg);
});
}
(5)对话列表相关历史记录
需要创建对话列表数据表,可参考
/** 聊天会话Id */ private Long id; /** 用户ID */ private Long userId; /** 聊天会话名 */ private String title; /** 业务类型 */ private String type;
其实还可以加个聊天记忆,但是由于我前面使用的是内存会话记忆,所以这里没有存储到数据库中,有时间的朋友可以试试。
创建之后就是简单的增删改查接口以及其他接口了。还有一些没贴出来
/**
* 查询聊天会话列表
*/
@GetMapping("/list")
public TableDataInfo list(ChatSession chatSession)
{
startPage();
List<ChatSession> list = chatSessionService.selectChatSessionList(chatSession);
return getDataTable(list);
}
/**
* 获取聊天会话详细信息
*/
@GetMapping(value = "/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{
return success(chatSessionService.selectChatSessionById(id));
}
/**
* 新增聊天会话
*/
@Log(title = "聊天会话", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody ChatSession chatSession)
{
Long id = chatSessionService.insertChatSession(chatSession);
return AjaxResult.success(id);
}
/**
* 删除聊天会话
*/
@PreAuthorize("@ss.hasPermi('ai:session:remove')")
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
{
return toAjax(chatSessionService.deleteChatSessionByIds(ids));
}
这里新增对话接口后端会返回一个会话Id给前端,前端后面再带会话Id过来
到这里就可以实现大概的ai对话功能了。
3.前端vue2
(1)依赖
由于要解析ai返回的带有样式的响应信息,这里需要使用marked,dompurify,我还引入了高亮样式
highlight.js
npm install marked@4.0.0 兼容问题使用较低版本
npm install dompurify
npm install highlight.js
"highlight.js": "9.18.5",
"dompurify": "^3.2.6",
我用的版本这个
(2)聊天接口
export function streamChat(sessionId, text, token) {
return new Promise((resolve, reject) => {
const eventSource = new EventSource(
`${process.env.VUE_APP_BASE_API}/ai/chat?chatId=${encodeURIComponent(sessionId)}&prompt=${encodeURIComponent(text)}`,
{
//可添加认证参数,目前我是开放了后端接口
}
)
eventSource.onopen = () => {
resolve(eventSource)
}
eventSource.onerror = (error) => {
eventSource.close()
}
})
(3)页面
对话主页面
<template>
<div class="app-container chat-container">
<div class="chat-sidebar">
<div class="sidebar-header">
<div class="logo-area">
<el-icon class="el-icon-chat-dot-square logo-icon" />
<span class="logo-text">Ai 机器人</span>
</div>
<el-button type="primary" class="new-chat-btn" @click="startNewChat">
<el-icon class="el-icon-edit-outline" />
<span>新建对话</span>
</el-button>
</div>
<div class="chat-history-container">
<div class="history-header">
<span class="history-title">历史对话</span>
<el-dropdown trigger="click" size="small">
<el-button type="text">
<el-icon class="el-icon-more" />
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item disabled>
<el-button type="text">导出记录</el-button>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="chat-history">
<div
v-for="(chat, index) in chatHistory"
:key="index"
class="chat-history-item"
:class="{ 'active': currentChatIndex === index }"
@click="switchChat(index)"
>
<el-icon class="el-icon-chat-round chat-icon" />
<div class="chat-info">
<div class="title">{{ chat.title }}</div>
<div class="time">{{ chat.createTime }}</div>
</div>
<el-dropdown trigger="hover" class="chat-actions">
<el-button type="text" class="action-btn">
<el-icon class="el-icon-more" />
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>
<el-button type="text" @click="deleteChat(index)">删除</el-button>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
</div>
<div class="chat-main">
<div class="chat-container-wrapper">
<div class="chat-messages" ref="scrollArea">
<div
v-for="(message, index) in messages"
:key="index"
class="message-wrapper"
>
<chat-message
:message="message"
:is-user="message.role === 'user'"
:show-timestamp="true"
/>
</div>
</div>
<div class="chat-input-wrapper">
<div class="chat-input">
<div class="input-wrapper">
<el-input
v-model="inputMessage"
type="textarea"
:rows="3"
placeholder="请输入您的问题..."
resize="none"
@keyup.enter.native="sendMessage"
/>
<el-button type="primary" @click="sendMessage" :loading="isLoading">
发送
<el-icon class="el-icon-s-promotion" />
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { streamChat } from "@/api/ai2/dialogue";
import { getToken } from "@/utils/auth";
import {addSession,delSession, listSession} from "@/api/ai2/chatSession";
import ChatMessage from "@/components/ChatMessage/index.vue";
import {listDetail} from "@/api/ai/chatSessionDetail";
export default {
name: "AiChat",
components: { ChatMessage },
data() {
return {
chatHistory: [
{
id: 1,
title: "默认对话",
createTime: new Date().toLocaleString(),
messages: [
{
role: "assistant",
content: "您好!我是AI助手,有什么可以帮助您的吗?",
createTime: Date.now() - 3600000
},
],
},
],
messages: [],
inputMessage: "",
selectedModel: "deepseek-1-8b",
chatMode: "base",
currentChatIndex: 0,
isLoading: false,
eventSource: null,
open: false,
form: {},
rules: {
title: [{ required: true, message: "名称不能为空", trigger: "blur" }],
}
};
},
async created() {
await this.listChatHistory();
this.initialSession();
},
methods: {
async listChatHistory() {
const response = await listSession();
this.chatHistory = response.rows;
},
initialSession() {
if (this.chatHistory === null || this.chatHistory.length === 0) {
this.messages = [];
return;
}
this.switchChat(0)
},
startNewChat() {
console.log(111)
this.messages = [];
},
switchChat(index) {
this.currentChatIndex = index
let session = this.chatHistory[this.currentChatIndex];
listDetail({sessionId: session.id}).then(response => {
this.messages = response.rows;
});
},
// 发送消息时调用streamChat接口
async sendMessage() {
if (!this.inputMessage.trim() || this.isLoading) return;
this.isLoading = true;
if (this.messages.length === 0) {
let add = await addSession({title: this.inputMessage});
await this.listChatHistory();
this.currentChatIndex = 0;
this.chatHistory[this.currentChatIndex].id = add.data
}
let userMsg = {sessionId: this.chatHistory[this.currentChatIndex].id, role: 'user', content: this.inputMessage, createTime: Date.now()};
this.messages.push(userMsg);
this.inputMessage = '';
try {
this.eventSource = await streamChat(
this.chatHistory[this.currentChatIndex].id,
this.messages[this.messages.length - 1].content,
getToken()
);
let assistantMsg = {sessionId: this.chatHistory[this.currentChatIndex].id, role: 'assistant', content: '', createTime: Date.now()};
this.messages.push(assistantMsg);
this.eventSource.onmessage = async (event) => {
if (event.data) {
assistantMsg.content += event.data;
this.$nextTick(() => this.scrollToBottom());
}
};
this.eventSource.onerror = async (e) => {
this.isLoading = false;
this.eventSource.close();
};
} catch (error) {
console.error('API Error:', error);
this.isLoading = false;
}
},
deleteChat(index){
const chat = this.chatHistory[index];
this.$modal.confirm('确定要删除会话"' + chat.title + '"吗,删除后聊天记录不可恢复?').then(function () {
return delSession(chat.id);
}).then(() => {
this.listChatHistory();
this.$modal.msgSuccess('删除成功')
}).catch(() => {
})
},
scrollToBottom() {
const container = this.$refs.scrollArea;
if (container) {
container.scrollTop = container.scrollHeight;
}
}
},
beforeUnmount() {
if (this.eventSource) this.eventSource.close();
},
};
</script>
<style scoped lang="scss">
.chat-container {
display: flex;
height: 90vh;
background-color: #f5f7fa;
}
.chat-sidebar {
width: 280px;
display: flex;
border-radius: 12px;
flex-direction: column;
background: #fff;
border-right: 1px solid #e6e6e6;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
.sidebar-header {
padding: 16px;
border-bottom: 1px solid #eee;
.logo-area {
display: flex;
align-items: center;
margin-bottom: 16px;
.logo-icon {
color: #1890ff;
margin-left: 40px;
margin-right: 20px;
font-size: 25px;
}
.logo-text {
font-size: 20px;
font-weight: 600;
color: #333;
}
}
.new-chat-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 40px;
border-radius: 8px;
transition: all 0.3s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
}
.chat-history-container {
flex: 1;
overflow: hidden;
padding: 16px 8px;
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
margin-bottom: 12px;
.history-title {
font-size: 13px;
color: #666;
font-weight: 500;
}
}
}
.chat-history {
height: 95%;
overflow-y: auto;
&-item {
display: flex;
align-items: center;
padding: 10px 12px;
margin: 2px 0;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
position: relative;
&:hover {
background: #f5f7fa;
.chat-actions {
opacity: 1;
}
}
&.active {
background: #e6f4ff;
.title {
color: #1890ff;
}
}
.chat-icon {
font-size: 18px;
color: #666;
margin-right: 15px;
}
.chat-info {
flex: 1;
min-width: 0;
.title {
text-align: left;
font-size: 14px;
color: #333;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.time {
font-size: 12px;
color: #999;
}
}
.chat-actions {
opacity: 0;
transition: opacity 0.2s;
.action-btn {
padding: 2px;
}
}
}
}
}
.chat-main {
flex: 1;
margin-left: 15px;
margin-right: 15px;
border-radius: 12px;
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
.chat-container-wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
}
.chat-messages {
flex: 1;
padding: 20px;
overflow-y: auto;
.message {
display: flex;
margin-bottom: 20px;
&-user {
flex-direction: row-reverse;
.message-content {
margin-right: 12px;
.message-text {
background: #e3f2fd;
border-radius: 12px 2px 12px 12px;
}
}
}
&-ai .message-content {
margin-left: 12px;
.message-text {
background: #f5f5f5;
border-radius: 2px 12px 12px 12px;
}
}
.message-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.message-content {
max-width: 70%;
}
.message-text {
padding: 12px 16px;
font-size: 14px;
line-height: 1.5;
}
.message-time {
margin-top: 4px;
font-size: 12px;
color: #999;
}
}
}
.chat-input-wrapper {
flex-shrink: 0;
background: #fff;
border-top: 1px solid #eee;
.chat-input {
padding: 20px;
.input-wrapper {
display: flex;
gap: 10px;
.el-input {
flex: 1;
}
.el-button {
align-self: flex-end;
}
}
}
}
.chat-settings {
width: 300px;
.el-card {
height: 100%;
}
}
</style>
聊天对话页面组件,解析ai响应结果
<template>
<div :class="['message', isUser ? 'message-user' : 'message-ai']">
<div class="message-avatar">
<img
:src="isUser ? require('@/assets/images/profile.jpg') :
selectedModel === 'qwq-32b' ? require('@/assets/images/ai/ai.png') : require('@/assets/images/ai/ai.png')"
:alt="isUser ? '用户' : 'AI'"
/>
</div>
<div class="message-content">
<div class="text-container" ref="contentRef">
<button v-if="showCopyButton" class="copy-button" @click="copyContent" :title="copyButtonTitle">
<i v-if="!copied" class="el-icon-document-copy copy-icon" />
<i v-else class="el-icon-check copy-icon copied" />
</button>
<div class="text" v-if="isUser">
{{ message.content }}
</div>
<div class="text markdown-content" v-else v-html="processedContent"></div>
</div>
<div class="message-time" v-if="showTimestamp">{{ formatTime(message.createTime) }}</div>
</div>
</div>
</template>
<script>
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import hljs from 'highlight.js'
import javascript from 'highlight.js/lib/languages/javascript';
import python from 'highlight.js/lib/languages/python';
import 'highlight.js/styles/default.css';
// 注册语言
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('python', python);
export default {
name: 'ChatMessage',
props: {
message: {
type: Object,
required: true
},
isUser: {
type: Boolean,
default: false
},
showTimestamp: {
type: Boolean,
default: true
},
selectedModel: {
type: String,
default: 'deepseek-1-8b'
},
showCopyButton: {
type: Boolean,
default: true
}
},
data() {
return {
contentRef: null,
copied: false,
accumulatedMarkdown: ''
}
},
computed: {
copyButtonTitle() {
return this.copied ? '已复制' : '复制内容'
},
processedContent() {
if (!this.message.content) return ''
return this.processContent(this.message.content)
}
},
mounted() {
if (!this.isUser) {
this.highlightCode()
}
this.contentRef = this.$refs.contentRef;
},
watch: {
'message.content': function () {
if (!this.isUser) {
this.highlightCode()
}
}
},
methods: {
initMarked() {
marked.setOptions({
breaks: true,
gfm: true,
sanitize: false
})
},
processContent(content) {
if (!content) return ''
let result = ''
let isInThinkBlock = false
let currentBlock = ''
for (let i = 0; i < content.length; i++) {
if (content.slice(i, i + 7) === '<|FunctionCallBegin|>') {
isInThinkBlock = true
if (currentBlock) {
result += marked.parse(currentBlock)
}
currentBlock = ''
i += 6 // 跳过<RichMediaReference>
continue
}
if (content.slice(i, i + 8) === '<|FunctionCallEnd|>') {
isInThinkBlock = false
result += `<div class="think-block">${marked.parse(currentBlock)}</div>`
currentBlock = ''
i += 7 // 跳过<|FunctionCallEnd|>
continue
}
currentBlock += content[i]
}
if (currentBlock) {
if (isInThinkBlock) {
result += `<div class="think-block">${marked.parse(currentBlock)}</div>`
} else {
result += marked.parse(currentBlock)
}
}
const cleanHtml = DOMPurify.sanitize(result, {
ADD_TAGS: ['think', 'code', 'pre', 'span'],
ADD_ATTR: ['class', 'language']
})
const tempDiv = document.createElement('div')
tempDiv.innerHTML = cleanHtml
const preElements = tempDiv.querySelectorAll('pre')
preElements.forEach(pre => {
const code = pre.querySelector('code')
if (code) {
if (!code.className) {
code.className = 'language-text';
}
const wrapper = document.createElement('div')
wrapper.className = 'code-block-wrapper'
const copyBtn = document.createElement('button')
copyBtn.className = 'code-copy-button'
copyBtn.title = '复制代码'
copyBtn.innerHTML = `<i class="el-icon-document-copy code-copy-icon"></i>`
const successMsg = document.createElement('div')
successMsg.className = 'copy-success-message'
successMsg.textContent = '已复制!'
wrapper.appendChild(copyBtn)
wrapper.appendChild(pre.cloneNode(true))
wrapper.appendChild(successMsg)
pre.parentNode.replaceChild(wrapper, pre)
}
})
return tempDiv.innerHTML
},
setupCodeBlockCopyButtons() {
if (!this.contentRef) return;
const codeBlocks = this.contentRef.querySelectorAll('.code-block-wrapper');
codeBlocks.forEach(block => {
const copyButton = block.querySelector('.code-copy-button');
const codeElement = block.querySelector('code');
const successMessage = block.querySelector('.copy-success-message');
if (copyButton && codeElement) {
copyButton.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.copyCode(codeElement.textContent || '', copyButton, successMessage);
});
}
});
},
copyCode(code, button, successMessage) {
return new Promise((resolve, reject) => {
if (!navigator.clipboard) {
this.fallbackCopyToClipboard(code)
.then(() => this.showCopySuccess(button, successMessage))
.catch(reject);
return;
}
navigator.clipboard.writeText(code)
.then(() => {
this.showCopySuccess(button, successMessage);
resolve();
})
.catch(err => {
console.error('复制失败:', err);
this.fallbackCopyToClipboard(code)
.then(() => {
this.showCopySuccess(button, successMessage);
resolve();
})
.catch(reject);
});
});
},
showCopySuccess(button, successMessage) {
if (!button.dataset.originalIcon) {
button.dataset.originalIcon = button.innerHTML;
}
button.innerHTML = '<i class="el-icon-check code-copy-icon copied"></i>';
if (successMessage) {
successMessage.classList.add('visible');
}
setTimeout(() => {
button.innerHTML = button.dataset.originalIcon;
if (successMessage) {
successMessage.classList.remove('visible');
}
}, 2000);
},
fallbackCopyToClipboard(text) {
return new Promise((resolve, reject) => {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed'; // 防止影响页面布局
document.body.appendChild(textarea);
textarea.select();
try {
const success = document.execCommand('copy');
if (success) {
resolve();
} else {
reject(new Error('document.execCommand复制失败'));
}
} catch (err) {
reject(err);
} finally {
document.body.removeChild(textarea);
}
});
},
copyContent() {
let textToCopy = this.message.content;
if (!this.isUser && this.contentRef) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = this.processedContent;
textToCopy = tempDiv.textContent || tempDiv.innerText || '';
}
if (!navigator.clipboard) {
this.fallbackCopyToClipboard(textToCopy)
.then(() => {
this.copied = true;
setTimeout(() => {
this.copied = false;
}, 3000);
})
.catch(err => {
console.error('复制失败:', err);
});
return;
}
navigator.clipboard.writeText(textToCopy)
.then(() => {
this.copied = true;
setTimeout(() => {
this.copied = false;
}, 3000);
})
.catch(err => {
console.error('复制失败:', err);
// 尝试使用备选方案
this.fallbackCopyToClipboard(textToCopy)
.then(() => {
this.copied = true;
setTimeout(() => {
this.copied = false;
}, 3000);
})
.catch(err => {
console.error('备选方案复制也失败:', err);
});
});
},
highlightCode() {
this.$nextTick(() => {
if (this.contentRef) {
const codeBlocks = this.contentRef.querySelectorAll('pre code');
codeBlocks.forEach((block) => {
if (!block.className.includes('language-')) {
block.className += ' language-text';
}
// 使用 highlightElement 或 highlightAuto
if (typeof hljs.highlightElement === 'function') {
hljs.highlightElement(block);
} else {
// 备选方案
const result = hljs.highlightAuto(block.textContent);
block.innerHTML = result.value;
block.classList.add(`language-${result.language}`);
}
});
this.setupCodeBlockCopyButtons();
}
});
},
formatTime(timestamp) {
if (!timestamp) return ''
return new Date(timestamp).toLocaleTimeString();
}
}
}
</script>
<style scoped lang="scss">
.code-block-wrapper {
background-color: #00afff !important;
}
.message {
display: flex;
margin-bottom: 1.5rem;
gap: 1rem;
&.message-user {
flex-direction: row-reverse;
.message-content {
margin-right: 12px;
.text {
background: #e3f2fd;
border-radius: 12px 2px 12px 12px;
}
}
}
&.message-ai .message-content {
margin-left: 12px;
.text {
background: #f5f5f5;
border-radius: 2px 12px 12px 12px;
}
}
.message-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.message-content {
max-width: 70%;
}
.text-container {
position: relative;
}
.copy-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: rgba(255, 255, 255, 0.7);
border: none;
color: #666;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
display: flex;
align-items: center;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s;
z-index: 10;
&:hover {
background-color: rgba(255, 255, 255, 0.9);
}
.copy-icon {
width: 14px;
height: 14px;
}
&.copied {
color: #4ade80;
}
}
.text-container:hover .copy-button {
opacity: 0.8;
}
.text {
padding: 1rem;
font-size: 14px;
line-height: 1.5;
position: relative;
word-break: break-word;
.cursor {
animation: blink 1s infinite;
}
::v-deep .think-block {
position: relative;
padding: 0.75rem 1rem 0.75rem 1.5rem;
margin: 0.5rem 0;
color: #666;
font-style: italic;
border-left: 4px solid #ddd;
background-color: rgba(0, 0, 0, 0.03);
border-radius: 0 0.5rem 0.5rem 0;
&::before {
content: '思考';
position: absolute;
top: -0.75rem;
left: 1rem;
padding: 0 0.5rem;
font-size: 0.75rem;
background: #f5f5f5;
border-radius: 0.25rem;
color: #999;
font-style: normal;
}
}
// 重点调整代码块样式,让高亮背景和消息背景区分开
::v-deep pre {
background: #f8f9fa; /* 单独给代码块设置背景色,和消息背景区分 */
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 0.5rem 0;
border: 1px solid #e1e4e8;
}
::v-deep code {
background-color: transparent;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-family: Consolas, monospace;
}
}
.message-time {
margin-top: 4px;
font-size: 12px;
color: #999;
}
.dark {
.message {
.message-user .text {
background: #1a365d;
color: #fff;
}
.message-ai .text {
background: #1e1e1e;
color: #e0e0e0;
}
.copy-button {
background: rgba(0, 0, 0, 0.5);
color: #999;
&:hover {
background-color: rgba(0, 0, 0, 0.7);
}
&.copied {
color: #4ade80;
}
}
::v-deep .think-block {
background-color: rgba(255, 255, 255, 0.03);
border-left-color: #666;
color: #999;
&::before {
background: #2a2a2a;
color: #888;
}
}
::v-deep pre {
background: #161b22;
border-color: #30363d;
}
}
}
::v-deep .code-block-wrapper {
position: relative;
margin: 1rem 0;
border-radius: 6px;
overflow: hidden;
background-color: white !important;
.code-copy-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: rgba(255, 255, 255, 0.1);
border: none;
color: #e6e6e6;
cursor: pointer;
padding: 0.25rem;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s, background-color 0.2s;
z-index: 10;
&:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.code-copy-icon {
width: 16px;
height: 16px;
color: #1ab394;
}
}
&:hover .code-copy-button {
opacity: 0.8;
}
pre {
margin: 0;
padding: 1rem;
background: transparent;
overflow-x: auto;
code {
background: transparent;
padding: 0;
font-family: ui-monospace, monospace;
}
}
.copy-success-message {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: rgba(74, 222, 128, 0.9);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.3s, transform 0.3s;
pointer-events: none;
z-index: 20;
&.visible {
opacity: 1;
transform: translateY(0);
}
}
}
}
</style>
4.效果展示
现在基本实现了ai对话功能,百炼平台调用deepseek模型比通义千问要慢一些,通义千问几乎是立马回答。
总结:Spring AI 是由 Pivotal 团队开发的开源框架,旨在简化企业级 AI 应用的开发。它提供了统一的 API 接口,无缝集成 OpenAI、Hugging Face 等主流 AI 服务提供商,支持大型语言模型(LLM)、向量数据库、文本生成、嵌入等核心 AI 功能。框架采用响应式编程模型,结合 Spring Boot 的自动配置特性,让开发者能够轻松构建智能聊天机器人、智能客服、内容生成等应用,同时提供内存管理、提示工程、消息序列化等工具,有效降低了 AI 技术在企业应用中的落地门槛。大家可以学习了解一下