当前位置: 首页 > news >正文

【SpringAI实战】实现仿DeepSeek页面对话机器人

一、实现效果

二、代码实现

        2.1 后端代码

        2.2 前端代码 


一、实现效果

可以保存聊天记录与会话记录

二、代码实现

2.1 后端代码

pom.xml

    <!-- 继承Spring Boot父POM,提供默认依赖管理 --><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.4.3</version> <!-- Spring Boot版本 --><relativePath/> <!-- 优先从本地仓库查找 --></parent><!-- 自定义属性 --><properties><java.version>17</java.version> <!-- JDK版本要求 --><spring-ai.version>1.0.0-M6</spring-ai.version> <!-- Spring AI里程碑版本 --></properties><!-- 项目依赖 --><dependencies><!-- Spring Boot Web支持 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!-- AI相关依赖 --><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-ollama-spring-boot-starter</artifactId> <!-- Ollama集成 --></dependency><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-openai-spring-boot-starter</artifactId> <!-- OpenAI集成 --></dependency><!-- 开发工具 --><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>1.18.22</version> <!-- 注解简化代码 --><scope>provided</scope> <!-- 编译期使用 --></dependency></dependencies><!-- 依赖管理(统一Spring AI家族版本) --><dependencyManagement><dependencies><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-bom</artifactId><version>${spring-ai.version}</version><type>pom</type><scope>import</scope> <!-- 导入BOM管理版本 --></dependency></dependencies></dependencyManagement>

 application.ymal

可选择ollama或者openai其一进行大模型配置

spring:application:name: spring-ai-dome  # 应用名称(用于服务发现和监控)# AI服务配置(多引擎支持)ai:# Ollama配置(本地大模型引擎)ollama:base-url: http://localhost:11434  # Ollama服务地址(默认端口11434)chat:model: deepseek-r1:7b  # 使用的模型名称(7B参数的本地模型)# 阿里云OpenAI兼容模式配置openai:base-url: https://dashscope.aliyuncs.com/compatible-mode  # 阿里云兼容API端点api-key: ${OPENAI_API_KEY}  # 从环境变量读取API密钥(安全建议)chat:options:model: qwen-max-latest  # 通义千问最新版本模型# 日志级别配置
logging:level:org.springframework.ai: debug  # 打印Spring AI框架调试日志com.itheima.ai: debug         # 打印业务代码调试日志

ChatConfiguration配置类 

InMemoryChatMemory实现本地聊天记录存储

/*** AI核心配置类** 核心组件:* 1. 聊天记忆管理(ChatMemory)* 2. 多种场景的ChatClient实例*/
@Configuration
public class ChatConfiguration {/*** 内存式聊天记忆存储* @return InMemoryChatMemory 实例** 作用:保存对话上下文,实现多轮对话能力* 实现原理:基于ConcurrentHashMap的线程安全实现*/@Beanpublic ChatMemory chatMemory() {return new InMemoryChatMemory();}/*** 通用聊天客户端* @param model 阿里云OpenAI模型* @param chatMemory 聊天记忆* @return 配置好的ChatClient** 默认配置:* - 使用qwen-omni-turbo模型* - 设定AI人格为"小小"* - 启用日志记录和记忆功能*/@Beanpublic ChatClient chatClient(AlibabaOpenAiChatModel model, ChatMemory chatMemory) {return ChatClient.builder(model).defaultOptions(ChatOptions.builder().model("qwen-omni-turbo").build()).defaultSystem("你是一个热心、聪明、全知的智能助手,你的名字叫小小,请以小小的身份和语气回答问题。").defaultAdvisors(new SimpleLoggerAdvisor(),  // 日志记录new MessageChatMemoryAdvisor(chatMemory)  // 记忆功能).build();}}

ChatController对话类

会话id由前端进行生成并传输过来,当然也可后端自己生成并且存入数据库,不过这里由于是简单的实现,由本地Map实现会话及信息的存储

import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.client.ChatClient;
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 static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY;/*** AI聊天对话控制器* 功能:提供基于大模型的流式对话接口* * 技术特性:* - 支持多轮对话记忆(通过chatId区分会话)* - 响应式流式返回(Flux<String>)* - 自动会话历史记录* * 访问方式:GET /ai/chat?prompt=问题&chatId=会话ID*/
@RequiredArgsConstructor // Lombok自动生成构造函数(依赖注入)
@RestController
@RequestMapping("/ai") // 基础路径
public class ChatController {private final ChatClient chatClient; // Spring AI聊天客户端private final ChatHistoryRepository chatHistoryRepository; // 会话历史存储/*** 流式聊天接口* @param prompt 用户输入的问题/指令* @param chatId 会话唯一标识(用于多轮对话)* @return Flux<String> 流式响应(SSE协议)* * 处理流程:* 1. 记录会话ID到数据库* 2. 通过ChatClient发起流式请求* 3. 实时返回模型生成内容*/@RequestMapping(value = "/chat", produces = "text/html;charset=utf-8")public Flux<String> chat(@RequestParam("prompt") String prompt,@RequestParam("chatId") String chatId) {// 会话持久化(可扩展为保存完整对话历史)chatHistoryRepository.save("chat", chatId);// 发起流式对话请求return textChat(prompt, chatId);}/*** 核心对话方法(私有)* @param prompt 用户输入* @param chatId 会话ID* @return 模型响应流* * 技术细节:* - 通过CHAT_MEMORY_CONVERSATION_ID_KEY绑定会话记忆* - content()方法自动转换为流式响应*/private Flux<String> textChat(String prompt, String chatId) {return chatClient.prompt().user(prompt) // 设置用户输入.advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)) // 关联会话记忆.stream() // 启用流式模式.content(); // 获取纯文本内容流}
}

ChatHistoryController会话历史类

实现本地Map存储chat类型与所有会话历史的对应关系,找到会话后就可用根据聊天记忆ChatMemory找到聊天历史

import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;@RequiredArgsConstructor
@RestController
@RequestMapping("/ai/history")
public class ChatHistoryController {private final ChatHistoryRepository chatHistoryRepository;private final ChatMemory chatMemory;@GetMapping("/{type}")public List<String> getChatIds(@PathVariable("type") String type) {return chatHistoryRepository.getChatIds(type);}@GetMapping("/{type}/{chatId}")public List<MessageVO> getChatHistory(@PathVariable("type") String type, @PathVariable("chatId") String chatId) {List<Message> messages = chatMemory.get(chatId, Integer.MAX_VALUE);if(messages == null) {return List.of();}// 转换成VOreturn messages.stream().map(MessageVO::new).toList();}
}

ChatHistoryRepository业务接口 

import java.util.List;public interface ChatHistoryRepository {/*** 保存会话记录* @param type 业务类型,如:chat、service、pdf* @param chatId 会话ID*/void save(String type, String chatId);/*** 获取会话ID列表* @param type 业务类型,如:chat、service、pdf* @return 会话ID列表*/List<String> getChatIds(String type);
}

InMemoryChatHistoryRepository实现类

@Slf4j
@Component
@RequiredArgsConstructor
public class InMemoryChatHistoryRepository implements ChatHistoryRepository {// 会话chatId存储Mapprivate Map<String, List<String>> chatHistory;private final ChatMemory chatMemory;// 保存会话ID@Overridepublic void save(String type, String chatId) {/*if (!chatHistory.containsKey(type)) {chatHistory.put(type, new ArrayList<>());}List<String> chatIds = chatHistory.get(type);*/List<String> chatIds = chatHistory.computeIfAbsent(type, k -> new ArrayList<>());if (chatIds.contains(chatId)) {return;}chatIds.add(chatId);}// 获取所有会话id@Overridepublic List<String> getChatIds(String type) {/*List<String> chatIds = chatHistory.get(type);return chatIds == null ? List.of() : chatIds;*/return chatHistory.getOrDefault(type, List.of());}}

MessageVO返回实体类 

根据ChatMemory中存储的Message可知有四种类型,则根据Message来示例VO对象

USER("user"),
ASSISTANT("assistant"),
SYSTEM("system"),
TOOL("tool");

import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.ai.chat.messages.Message;@NoArgsConstructor
@Data
public class MessageVO {private String role;private String content;public MessageVO(Message message) {switch (message.getMessageType()) {case USER:role = "user";break;case ASSISTANT:role = "assistant";break;default:role = "";break;}this.content = message.getText();}
}

2.2 前端代码

可以根据这些代码与接口让Cursor生成一个仿deepseek页面即可实现,或者根据下列代码修改实现(实现效果中的代码)

AIChat.vue

<template><div class="ai-chat" :class="{ 'dark': isDark }"><div class="chat-container"><div class="sidebar"><div class="history-header"><h2>聊天记录</h2><button class="new-chat" @click="startNewChat"><PlusIcon class="icon" />新对话</button></div><div class="history-list"><div v-for="chat in chatHistory" :key="chat.id"class="history-item":class="{ 'active': currentChatId === chat.id }"@click="loadChat(chat.id)"><ChatBubbleLeftRightIcon class="icon" /><span class="title">{{ chat.title || '新对话' }}</span></div></div></div><div class="chat-main"><div class="messages" ref="messagesRef"><ChatMessagev-for="(message, index) in currentMessages":key="index":message="message":is-stream="isStreaming && index === currentMessages.length - 1"/></div><div class="input-area"><div v-if="selectedFiles.length > 0" class="selected-files"><div v-for="(file, index) in selectedFiles" :key="index" class="file-item"><div class="file-info"><DocumentIcon class="icon" /><span class="file-name">{{ file.name }}</span><span class="file-size">({{ formatFileSize(file.size) }})</span></div><button class="remove-btn" @click="removeFile(index)"><XMarkIcon class="icon" /></button></div></div><div class="input-row"><div class="file-upload"><input type="file" ref="fileInput"@change="handleFileUpload"accept="image/*,audio/*,video/*"multipleclass="hidden"><button class="upload-btn"@click="triggerFileInput":disabled="isStreaming"><PaperClipIcon class="icon" /></button></div><textareav-model="userInput"@keydown.enter.prevent="sendMessage":placeholder="getPlaceholder()"rows="1"ref="inputRef"></textarea><button class="send-button" @click="sendMessage":disabled="isStreaming || (!userInput.trim() && !selectedFiles.length)"><PaperAirplaneIcon class="icon" /></button></div></div></div></div></div>
</template><script setup>
import { ref, onMounted, nextTick } from 'vue'
import { useDark } from '@vueuse/core'
import { ChatBubbleLeftRightIcon, PaperAirplaneIcon,PlusIcon,PaperClipIcon,DocumentIcon,XMarkIcon
} from '@heroicons/vue/24/outline'
import ChatMessage from '../components/ChatMessage.vue'
import { chatAPI } from '../services/api'const isDark = useDark()
const messagesRef = ref(null)
const inputRef = ref(null)
const userInput = ref('')
const isStreaming = ref(false)
const currentChatId = ref(null)
const currentMessages = ref([])
const chatHistory = ref([])
const fileInput = ref(null)
const selectedFiles = ref([])// 自动调整输入框高度
const adjustTextareaHeight = () => {const textarea = inputRef.valueif (textarea) {textarea.style.height = 'auto'textarea.style.height = textarea.scrollHeight + 'px'}else{textarea.style.height = '50px'}
}// 滚动到底部
const scrollToBottom = async () => {await nextTick()if (messagesRef.value) {messagesRef.value.scrollTop = messagesRef.value.scrollHeight}
}// 文件类型限制
const FILE_LIMITS = {image: { maxSize: 10 * 1024 * 1024,  // 单个文件 10MBmaxFiles: 3,                 // 最多 3 个文件description: '图片文件'},audio: { maxSize: 10 * 1024 * 1024,  // 单个文件 10MBmaxDuration: 180,           // 3分钟maxFiles: 3,                // 最多 3 个文件description: '音频文件'},video: { maxSize: 150 * 1024 * 1024, // 单个文件 150MBmaxDuration: 40,            // 40秒maxFiles: 3,                // 最多 3 个文件description: '视频文件'}
}// 触发文件选择
const triggerFileInput = () => {fileInput.value?.click()
}// 检查文件是否符合要求
const validateFile = async (file) => {const type = file.type.split('/')[0]const limit = FILE_LIMITS[type]if (!limit) {return { valid: false, error: '不支持的文件类型' }}if (file.size > limit.maxSize) {return { valid: false, error: `文件大小不能超过${limit.maxSize / 1024 / 1024}MB` }}if ((type === 'audio' || type === 'video') && limit.maxDuration) {try {const duration = await getMediaDuration(file)if (duration > limit.maxDuration) {return { valid: false, error: `${type === 'audio' ? '音频' : '视频'}时长不能超过${limit.maxDuration}秒`}}} catch (error) {return { valid: false, error: '无法读取媒体文件时长' }}}return { valid: true }
}// 获取媒体文件时长
const getMediaDuration = (file) => {return new Promise((resolve, reject) => {const element = file.type.startsWith('audio/') ? new Audio() : document.createElement('video')element.preload = 'metadata'element.onloadedmetadata = () => {resolve(element.duration)URL.revokeObjectURL(element.src)}element.onerror = () => {reject(new Error('无法读取媒体文件'))URL.revokeObjectURL(element.src)}element.src = URL.createObjectURL(file)})
}// 修改文件上传处理函数
const handleFileUpload = async (event) => {const files = Array.from(event.target.files || [])if (!files.length) return// 检查所有文件类型是否一致const firstFileType = files[0].type.split('/')[0]const hasInconsistentType = files.some(file => file.type.split('/')[0] !== firstFileType)if (hasInconsistentType) {alert('请选择相同类型的文件(图片、音频或视频)')event.target.value = ''return}// 验证所有文件for (const file of files) {const { valid, error } = await validateFile(file)if (!valid) {alert(error)event.target.value = ''selectedFiles.value = []return}}// 检查文件总大小const totalSize = files.reduce((sum, file) => sum + file.size, 0)const limit = FILE_LIMITS[firstFileType]if (totalSize > limit.maxSize * 3) { // 允许最多3个文件的总大小alert(`${firstFileType === 'image' ? '图片' : firstFileType === 'audio' ? '音频' : '视频'}文件总大小不能超过${(limit.maxSize * 3) / 1024 / 1024}MB`)event.target.value = ''selectedFiles.value = []return}selectedFiles.value = files
}// 修改文件输入提示
const getPlaceholder = () => {if (selectedFiles.value.length > 0) {const type = selectedFiles.value[0].type.split('/')[0]const desc = FILE_LIMITS[type].descriptionreturn `已选择 ${selectedFiles.value.length} 个${desc},可继续输入消息...`}return '输入消息,可上传图片、音频或视频...'
}// 修改发送消息函数
const sendMessage = async () => {if (isStreaming.value) returnif (!userInput.value.trim() && !selectedFiles.value.length) returnconst messageContent = userInput.value.trim()// 添加用户消息const userMessage = {role: 'user',content: messageContent,timestamp: new Date()}currentMessages.value.push(userMessage)// 清空输入userInput.value = ''adjustTextareaHeight()await scrollToBottom()// 准备发送数据const formData = new FormData()if (messageContent) {formData.append('prompt', messageContent)}selectedFiles.value.forEach(file => {formData.append('files', file)})// 添加助手消息占位const assistantMessage = {role: 'assistant',content: '',timestamp: new Date()}currentMessages.value.push(assistantMessage)isStreaming.value = truetry {const reader = await chatAPI.sendMessage(formData, currentChatId.value)const decoder = new TextDecoder('utf-8')let accumulatedContent = ''  // 添加累积内容变量while (true) {try {const { value, done } = await reader.read()if (done) break// 累积新内容accumulatedContent += decoder.decode(value)  // 追加新内容await nextTick(() => {// 更新消息,使用累积的内容const updatedMessage = {...assistantMessage,content: accumulatedContent  // 使用累积的内容}const lastIndex = currentMessages.value.length - 1currentMessages.value.splice(lastIndex, 1, updatedMessage)})await scrollToBottom()} catch (readError) {console.error('读取流错误:', readError)break}}} catch (error) {console.error('发送消息失败:', error)assistantMessage.content = '抱歉,发生了错误,请稍后重试。'} finally {isStreaming.value = falseselectedFiles.value = [] // 清空已选文件fileInput.value.value = '' // 清空文件输入await scrollToBottom()}
}// 加载特定对话
const loadChat = async (chatId) => {currentChatId.value = chatIdtry {const messages = await chatAPI.getChatMessages(chatId, 'chat')currentMessages.value = messages} catch (error) {console.error('加载对话消息失败:', error)currentMessages.value = []}
}// 加载聊天历史
const loadChatHistory = async () => {try {const history = await chatAPI.getChatHistory('chat')chatHistory.value = history || []if (history && history.length > 0) {await loadChat(history[0].id)} else {startNewChat()}} catch (error) {console.error('加载聊天历史失败:', error)chatHistory.value = []startNewChat()}
}// 开始新对话
const startNewChat = () => {const newChatId = Date.now().toString()currentChatId.value = newChatIdcurrentMessages.value = []// 添加新对话到聊天历史列表const newChat = {id: newChatId,title: `对话 ${newChatId.slice(-6)}`}chatHistory.value = [newChat, ...chatHistory.value] // 将新对话添加到列表开头
}// 格式化文件大小
const formatFileSize = (bytes) => {if (bytes < 1024) return bytes + ' B'if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}// 移除文件
const removeFile = (index) => {selectedFiles.value = selectedFiles.value.filter((_, i) => i !== index)if (selectedFiles.value.length === 0) {fileInput.value.value = ''  // 清空文件输入}
}onMounted(() => {loadChatHistory()adjustTextareaHeight()
})
</script><style scoped lang="scss">
.ai-chat {position: fixed;  // 修改为固定定位top: 64px;       // 导航栏高度left: 0;right: 0;bottom: 0;display: flex;background: var(--bg-color);overflow: hidden; // 防止页面滚动.chat-container {flex: 1;display: flex;max-width: 1800px;width: 100%;margin: 0 auto;padding: 1.5rem 2rem;gap: 1.5rem;height: 100%;    // 确保容器占满高度overflow: hidden; // 防止容器滚动}.sidebar {width: 300px;display: flex;flex-direction: column;background: rgba(255, 255, 255, 0.95);backdrop-filter: blur(10px);border-radius: 1rem;box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);.history-header {flex-shrink: 0;  // 防止头部压缩padding: 1rem;display: flex;justify-content: space-between;align-items: center;h2 {font-size: 1.25rem;}.new-chat {display: flex;align-items: center;gap: 0.5rem;padding: 0.5rem 1rem;border-radius: 0.5rem;background: #007CF0;color: white;border: none;cursor: pointer;transition: background-color 0.3s;&:hover {background: #0066cc;}.icon {width: 1.25rem;height: 1.25rem;}}}.history-list {flex: 1;overflow-y: auto;  // 允许历史记录滚动padding: 0 1rem 1rem;.history-item {display: flex;align-items: center;gap: 0.5rem;padding: 0.75rem;border-radius: 0.5rem;cursor: pointer;transition: background-color 0.3s;&:hover {background: rgba(255, 255, 255, 0.1);}&.active {background: rgba(0, 124, 240, 0.1);}.icon {width: 1.25rem;height: 1.25rem;}.title {flex: 1;overflow: hidden;text-overflow: ellipsis;white-space: nowrap;}}}}.chat-main {flex: 1;display: flex;flex-direction: column;background: rgba(255, 255, 255, 0.95);backdrop-filter: blur(10px);border-radius: 1rem;box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);overflow: hidden;  // 防止内容溢出.messages {flex: 1;overflow-y: auto;  // 只允许消息区域滚动padding: 2rem;}.input-area {flex-shrink: 0;padding: 1.5rem 2rem;background: rgba(255, 255, 255, 0.98);border-top: 1px solid rgba(0, 0, 0, 0.05);display: flex;flex-direction: column;gap: 1rem;.selected-files {background: rgba(0, 0, 0, 0.02);border-radius: 0.75rem;padding: 0.75rem;border: 1px solid rgba(0, 0, 0, 0.05);.file-item {display: flex;align-items: center;justify-content: space-between;padding: 0.75rem;background: #fff;border-radius: 0.5rem;margin-bottom: 0.75rem;border: 1px solid rgba(0, 0, 0, 0.05);transition: all 0.2s ease;&:last-child {margin-bottom: 0;}&:hover {background: rgba(0, 124, 240, 0.02);border-color: rgba(0, 124, 240, 0.2);}.file-info {display: flex;align-items: center;gap: 0.75rem;.icon {width: 1.5rem;height: 1.5rem;color: #007CF0;}.file-name {font-size: 0.875rem;color: #333;font-weight: 500;}.file-size {font-size: 0.75rem;color: #666;background: rgba(0, 0, 0, 0.05);padding: 0.25rem 0.5rem;border-radius: 1rem;}}.remove-btn {padding: 0.375rem;border: none;background: rgba(0, 0, 0, 0.05);color: #666;cursor: pointer;border-radius: 0.375rem;transition: all 0.2s ease;&:hover {background: #ff4d4f;color: #fff;}.icon {width: 1.25rem;height: 1.25rem;}}}}.input-row {display: flex;gap: 1rem;align-items: flex-end;background: #fff;padding: 0.75rem;border-radius: 1rem;border: 1px solid rgba(0, 0, 0, 0.1);box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);.file-upload {.hidden {display: none;}.upload-btn {width: 2.5rem;height: 2.5rem;display: flex;align-items: center;justify-content: center;border: none;border-radius: 0.75rem;background: rgba(0, 124, 240, 0.1);color: #007CF0;cursor: pointer;transition: all 0.2s ease;&:hover:not(:disabled) {background: rgba(0, 124, 240, 0.2);}&:disabled {opacity: 0.5;cursor: not-allowed;}.icon {width: 1.25rem;height: 1.25rem;}}}textarea {flex: 1;resize: none;border: none;background: transparent;padding: 0.75rem;color: inherit;font-family: inherit;font-size: 1rem;line-height: 1.5;max-height: 150px;&:focus {outline: none;}&::placeholder {color: #999;}}.send-button {width: 2.5rem;height: 2.5rem;display: flex;align-items: center;justify-content: center;border: none;border-radius: 0.75rem;background: #007CF0;color: white;cursor: pointer;transition: all 0.2s ease;&:hover:not(:disabled) {background: #0066cc;transform: translateY(-1px);}&:disabled {background: #ccc;cursor: not-allowed;}.icon {width: 1.25rem;height: 1.25rem;}}}}}
}.dark {.sidebar {background: rgba(40, 40, 40, 0.95);box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);}.chat-main {background: rgba(40, 40, 40, 0.95);box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);.input-area {background: rgba(30, 30, 30, 0.98);border-top: 1px solid rgba(255, 255, 255, 0.05);.selected-files {background: rgba(255, 255, 255, 0.02);border-color: rgba(255, 255, 255, 0.05);.file-item {background: rgba(255, 255, 255, 0.02);border-color: rgba(255, 255, 255, 0.05);&:hover {background: rgba(0, 124, 240, 0.1);border-color: rgba(0, 124, 240, 0.3);}.file-info {.icon {color: #007CF0;}.file-name {color: #fff;}.file-size {color: #999;background: rgba(255, 255, 255, 0.1);}}.remove-btn {background: rgba(255, 255, 255, 0.1);color: #999;&:hover {background: #ff4d4f;color: #fff;}}}}.input-row {background: rgba(255, 255, 255, 0.02);border-color: rgba(255, 255, 255, 0.05);box-shadow: none;textarea {color: #fff;&::placeholder {color: #666;}}.file-upload .upload-btn {background: rgba(0, 124, 240, 0.2);color: #007CF0;&:hover:not(:disabled) {background: rgba(0, 124, 240, 0.3);}}}}}.history-item {&:hover {background: rgba(255, 255, 255, 0.05) !important;}&.active {background: rgba(0, 124, 240, 0.2) !important;}}textarea {background: rgba(255, 255, 255, 0.05) !important;&:focus {background: rgba(255, 255, 255, 0.1) !important;}}.input-area {.file-upload {.upload-btn {background: rgba(255, 255, 255, 0.1);color: #999;&:hover:not(:disabled) {background: rgba(255, 255, 255, 0.2);color: #fff;}}}}
}@media (max-width: 768px) {.ai-chat {.chat-container {padding: 0;}.sidebar {display: none; // 在移动端隐藏侧边栏}.chat-main {border-radius: 0;}}
}
</style> 

ChatMessage.vue 

<template><div class="message" :class="{ 'message-user': isUser }"><div class="avatar"><UserCircleIcon v-if="isUser" class="icon" /><ComputerDesktopIcon v-else class="icon" :class="{ 'assistant': !isUser }" /></div><div class="content"><div class="text-container"><button v-if="isUser" class="user-copy-button" @click="copyContent" :title="copyButtonTitle"><DocumentDuplicateIcon v-if="!copied" class="copy-icon" /><CheckIcon v-else class="copy-icon copied" /></button><div class="text" ref="contentRef" v-if="isUser">{{ message.content }}</div><div class="text markdown-content" ref="contentRef" v-else v-html="processedContent"></div></div><div class="message-footer" v-if="!isUser"><button class="copy-button" @click="copyContent" :title="copyButtonTitle"><DocumentDuplicateIcon v-if="!copied" class="copy-icon" /><CheckIcon v-else class="copy-icon copied" /></button></div></div></div>
</template><script setup>
import { computed, onMounted, nextTick, ref, watch } from 'vue'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { UserCircleIcon, ComputerDesktopIcon, DocumentDuplicateIcon, CheckIcon } from '@heroicons/vue/24/outline'
import hljs from 'highlight.js'
import 'highlight.js/styles/github-dark.css'const contentRef = ref(null)
const copied = ref(false)
const copyButtonTitle = computed(() => copied.value ? '已复制' : '复制内容')// 配置 marked
marked.setOptions({breaks: true,gfm: true,sanitize: false
})// 处理内容
const processContent = (content) => {if (!content) return ''// 分析内容中的 think 标签let result = ''let isInThinkBlock = falselet currentBlock = ''// 逐字符分析,处理 think 标签for (let i = 0; i < content.length; i++) {if (content.slice(i, i + 7) === '<think>') {isInThinkBlock = trueif (currentBlock) {// 将之前的普通内容转换为 HTMLresult += marked.parse(currentBlock)}currentBlock = ''i += 6 // 跳过 <think>continue}if (content.slice(i, i + 8) === '</think>') {isInThinkBlock = false// 将 think 块包装在特殊 div 中result += `<div class="think-block">${marked.parse(currentBlock)}</div>`currentBlock = ''i += 7 // 跳过 </think>continue}currentBlock += content[i]}// 处理剩余内容if (currentBlock) {if (isInThinkBlock) {result += `<div class="think-block">${marked.parse(currentBlock)}</div>`} else {result += marked.parse(currentBlock)}}// 净化处理后的 HTMLconst cleanHtml = DOMPurify.sanitize(result, {ADD_TAGS: ['think', 'code', 'pre', 'span'],ADD_ATTR: ['class', 'language']})// 在净化后的 HTML 中查找代码块并添加复制按钮const tempDiv = document.createElement('div')tempDiv.innerHTML = cleanHtml// 查找所有代码块const preElements = tempDiv.querySelectorAll('pre')preElements.forEach(pre => {const code = pre.querySelector('code')if (code) {// 创建包装器const wrapper = document.createElement('div')wrapper.className = 'code-block-wrapper'// 添加复制按钮const copyBtn = document.createElement('button')copyBtn.className = 'code-copy-button'copyBtn.title = '复制代码'copyBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" class="code-copy-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>`// 添加成功消息const successMsg = document.createElement('div')successMsg.className = 'copy-success-message'successMsg.textContent = '已复制!'// 组装结构wrapper.appendChild(copyBtn)wrapper.appendChild(pre.cloneNode(true))wrapper.appendChild(successMsg)// 替换原始的 pre 元素pre.parentNode.replaceChild(wrapper, pre)}})return tempDiv.innerHTML
}// 修改计算属性
const processedContent = computed(() => {if (!props.message.content) return ''return processContent(props.message.content)
})// 为代码块添加复制功能
const setupCodeBlockCopyButtons = () => {if (!contentRef.value) return;const codeBlocks = contentRef.value.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) {// 移除旧的事件监听器const newCopyButton = copyButton.cloneNode(true);copyButton.parentNode.replaceChild(newCopyButton, copyButton);// 添加新的事件监听器newCopyButton.addEventListener('click', async (e) => {e.preventDefault();e.stopPropagation();try {const code = codeElement.textContent || '';await navigator.clipboard.writeText(code);// 显示成功消息if (successMessage) {successMessage.classList.add('visible');setTimeout(() => {successMessage.classList.remove('visible');}, 2000);}} catch (err) {console.error('复制代码失败:', err);}});}});
}// 在内容更新后手动应用高亮和设置复制按钮
const highlightCode = async () => {await nextTick()if (contentRef.value) {contentRef.value.querySelectorAll('pre code').forEach((block) => {hljs.highlightElement(block)})// 设置代码块复制按钮setupCodeBlockCopyButtons()}
}const props = defineProps({message: {type: Object,required: true}
})const isUser = computed(() => props.message.role === 'user')// 复制内容到剪贴板
const copyContent = async () => {try {// 获取纯文本内容let textToCopy = props.message.content;// 如果是AI回复,需要去除HTML标签if (!isUser.value && contentRef.value) {// 创建临时元素来获取纯文本const tempDiv = document.createElement('div');tempDiv.innerHTML = processedContent.value;textToCopy = tempDiv.textContent || tempDiv.innerText || '';}await navigator.clipboard.writeText(textToCopy);copied.value = true;// 3秒后重置复制状态setTimeout(() => {copied.value = false;}, 3000);} catch (err) {console.error('复制失败:', err);}
}// 监听内容变化
watch(() => props.message.content, () => {if (!isUser.value) {highlightCode()}
})// 初始化时也执行一次
onMounted(() => {if (!isUser.value) {highlightCode()}
})const formatTime = (timestamp) => {if (!timestamp) return ''return new Date(timestamp).toLocaleTimeString()
}
</script><style scoped lang="scss">
.message {display: flex;margin-bottom: 1.5rem;gap: 1rem;&.message-user {flex-direction: row-reverse;.content {align-items: flex-end;.text-container {position: relative;.text {background: #f0f7ff; // 浅色背景color: #333;border-radius: 1rem 1rem 0 1rem;}.user-copy-button {position: absolute;left: -30px;top: 50%;transform: translateY(-50%);background: transparent;border: none;width: 24px;height: 24px;display: flex;align-items: center;justify-content: center;cursor: pointer;opacity: 0;transition: opacity 0.2s;.copy-icon {width: 16px;height: 16px;color: #666;&.copied {color: #4ade80;}}}&:hover .user-copy-button {opacity: 1;}}.message-footer {flex-direction: row-reverse;}}}.avatar {width: 40px;height: 40px;flex-shrink: 0;.icon {width: 100%;height: 100%;color: #666;padding: 4px;border-radius: 8px;transition: all 0.3s ease;&.assistant {color: #333;background: #f0f0f0;&:hover {background: #e0e0e0;transform: scale(1.05);}}}}.content {display: flex;flex-direction: column;gap: 0.25rem;max-width: 80%;.text-container {position: relative;}.message-footer {display: flex;align-items: center;margin-top: 0.25rem;.time {font-size: 0.75rem;color: #666;}.copy-button {display: flex;align-items: center;gap: 0.25rem;background: transparent;border: none;font-size: 0.75rem;color: #666;padding: 0.25rem 0.5rem;border-radius: 4px;cursor: pointer;margin-right: auto;transition: background-color 0.2s;&:hover {background-color: rgba(0, 0, 0, 0.05);}.copy-icon {width: 14px;height: 14px;&.copied {color: #4ade80;}}.copy-text {font-size: 0.75rem;}}}.text {padding: 1rem;border-radius: 1rem 1rem 1rem 0;line-height: 1.5;white-space: pre-wrap;color: var(--text-color);.cursor {animation: blink 1s infinite;}: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;// 添加平滑过渡效果opacity: 1;transform: translateX(0);transition: opacity 0.3s ease, transform 0.3s ease;&::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;}// 添加进入动画&:not(:first-child) {animation: slideIn 0.3s ease forwards;}}:deep(pre) {background: #f6f8fa;padding: 1rem;border-radius: 0.5rem;overflow-x: auto;margin: 0.5rem 0;border: 1px solid #e1e4e8;code {background: transparent;padding: 0;font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;font-size: 0.9rem;line-height: 1.5;tab-size: 2;}}:deep(.hljs) {color: #24292e;background: transparent;}:deep(.hljs-keyword) {color: #d73a49;}:deep(.hljs-built_in) {color: #005cc5;}:deep(.hljs-type) {color: #6f42c1;}:deep(.hljs-literal) {color: #005cc5;}:deep(.hljs-number) {color: #005cc5;}:deep(.hljs-regexp) {color: #032f62;}:deep(.hljs-string) {color: #032f62;}:deep(.hljs-subst) {color: #24292e;}:deep(.hljs-symbol) {color: #e36209;}:deep(.hljs-class) {color: #6f42c1;}:deep(.hljs-function) {color: #6f42c1;}:deep(.hljs-title) {color: #6f42c1;}:deep(.hljs-params) {color: #24292e;}:deep(.hljs-comment) {color: #6a737d;}:deep(.hljs-doctag) {color: #d73a49;}:deep(.hljs-meta) {color: #6a737d;}:deep(.hljs-section) {color: #005cc5;}:deep(.hljs-name) {color: #22863a;}:deep(.hljs-attribute) {color: #005cc5;}:deep(.hljs-variable) {color: #e36209;}}}
}@keyframes blink {0%,100% {opacity: 1;}50% {opacity: 0;}
}@keyframes slideIn {from {opacity: 0;transform: translateX(-10px);}to {opacity: 1;transform: translateX(0);}
}.dark {.message {.avatar .icon {&.assistant {color: #fff;background: #444;&:hover {background: #555;}}}&.message-user {.content .text-container {.text {background: #1a365d; // 暗色模式下的浅蓝色背景color: #fff;}.user-copy-button {.copy-icon {color: #999;&.copied {color: #4ade80;}}}}}.content {.message-footer {.time {color: #999;}.copy-button {color: #999;&:hover {background-color: rgba(255, 255, 255, 0.1);}}}.text {:deep(.think-block) {background-color: rgba(255, 255, 255, 0.03);border-left-color: #666;color: #999;&::before {background: #2a2a2a;color: #888;}}:deep(pre) {background: #161b22;border-color: #30363d;code {color: #c9d1d9;}}:deep(.hljs) {color: #c9d1d9;background: transparent;}:deep(.hljs-keyword) {color: #ff7b72;}:deep(.hljs-built_in) {color: #79c0ff;}:deep(.hljs-type) {color: #ff7b72;}:deep(.hljs-literal) {color: #79c0ff;}:deep(.hljs-number) {color: #79c0ff;}:deep(.hljs-regexp) {color: #a5d6ff;}:deep(.hljs-string) {color: #a5d6ff;}:deep(.hljs-subst) {color: #c9d1d9;}:deep(.hljs-symbol) {color: #ffa657;}:deep(.hljs-class) {color: #f2cc60;}:deep(.hljs-function) {color: #d2a8ff;}:deep(.hljs-title) {color: #d2a8ff;}:deep(.hljs-params) {color: #c9d1d9;}:deep(.hljs-comment) {color: #8b949e;}:deep(.hljs-doctag) {color: #ff7b72;}:deep(.hljs-meta) {color: #8b949e;}:deep(.hljs-section) {color: #79c0ff;}:deep(.hljs-name) {color: #7ee787;}:deep(.hljs-attribute) {color: #79c0ff;}:deep(.hljs-variable) {color: #ffa657;}}&.message-user .content .text {background: #0066cc;color: white;}}}
}.markdown-content {:deep(p) {margin: 0.5rem 0;&:first-child {margin-top: 0;}&:last-child {margin-bottom: 0;}}:deep(ul),:deep(ol) {margin: 0.5rem 0;padding-left: 1.5rem;}:deep(li) {margin: 0.25rem 0;}:deep(code) {background: rgba(0, 0, 0, 0.05);padding: 0.2em 0.4em;border-radius: 3px;font-size: 0.9em;font-family: ui-monospace, monospace;}:deep(pre code) {background: transparent;padding: 0;}:deep(table) {border-collapse: collapse;margin: 0.5rem 0;width: 100%;}:deep(th),:deep(td) {border: 1px solid #ddd;padding: 0.5rem;text-align: left;}:deep(th) {background: rgba(0, 0, 0, 0.05);}:deep(blockquote) {margin: 0.5rem 0;padding-left: 1rem;border-left: 4px solid #ddd;color: #666;}:deep(.code-block-wrapper) {position: relative;margin: 1rem 0;border-radius: 6px;overflow: hidden;.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;}}&:hover .code-copy-button {opacity: 0.8;}pre {margin: 0;padding: 1rem;background: #1e1e1e;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);}}}
}.dark {.markdown-content {:deep(.code-block-wrapper) {.code-copy-button {background: rgba(255, 255, 255, 0.05);&:hover {background-color: rgba(255, 255, 255, 0.1);}}pre {background: #0d0d0d;}}:deep(code) {background: rgba(255, 255, 255, 0.1);}:deep(th),:deep(td) {border-color: #444;}:deep(th) {background: rgba(255, 255, 255, 0.1);}:deep(blockquote) {border-left-color: #444;color: #999;}}
}
</style>

api.js 接口调用js

const BASE_URL = 'http://localhost:8080'export const chatAPI = {// 发送聊天消息async sendMessage(data, chatId) {try {const url = new URL(`${BASE_URL}/ai/chat`)if (chatId) {url.searchParams.append('chatId', chatId)}const response = await fetch(url, {method: 'POST',body: data instanceof FormData ? data : new URLSearchParams({ prompt: data })})if (!response.ok) {throw new Error(`HTTP error! status: ${response.status}`)}return response.body.getReader()} catch (error) {console.error('API Error:', error)throw error}},// 获取聊天历史列表async getChatHistory(type = 'chat') {  // 添加类型参数try {const response = await fetch(`${BASE_URL}/ai/history/${type}`)if (!response.ok) {throw new Error(`HTTP error! status: ${response.status}`)}const chatIds = await response.json()// 转换为前端需要的格式return chatIds.map(id => ({id,title: type === 'pdf' ? `PDF对话 ${id.slice(-6)}` : type === 'service' ? `咨询 ${id.slice(-6)}` :`对话 ${id.slice(-6)}`}))} catch (error) {console.error('API Error:', error)return []}},// 获取特定对话的消息历史async getChatMessages(chatId, type = 'chat') {  // 添加类型参数try {const response = await fetch(`${BASE_URL}/ai/history/${type}/${chatId}`)if (!response.ok) {throw new Error(`HTTP error! status: ${response.status}`)}const messages = await response.json()// 添加时间戳return messages.map(msg => ({...msg,timestamp: new Date() // 由于后端没有提供时间戳,这里临时使用当前时间}))} catch (error) {console.error('API Error:', error)return []}},

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

http://www.dtcms.com/a/292444.html

相关文章:

  • 风能革新!5大理由选Canopen转Profinet网关
  • 基于python django深度学习的中文文本检测+识别,可以前端上传图片和后台管理图片
  • Oracle使用小计
  • VUE2 项目学习笔记 ? 语法 v-if/v-show
  • C# 11.0 新特性 u8 后缀
  • 【数学建模|Matlab】Matlab「基础知识」和「基础操作」
  • halcon处理灰度能量图
  • Android FFMPEG-播放器画面适配
  • 深入理解程序链接机制:静态链接、ELF加载与动态库实现原理
  • 三步构建智能办公体系
  • 330米地标背后的“智慧神经” —— 越秀国际金融汇电力系统案例解析
  • spring boot 集成netty,及其一些基本概念
  • 黑马点评练习题-给店铺类型查询业务添加缓存(String和List实现)
  • android模拟器手机打开本地网页
  • 科技向善,银发向暖:智慧养老与经济共筑适老未来
  • 如何给手机充电才不伤电池?
  • 未来趋势:LeafletJS 与 Web3/AI 的融合
  • ArcGIS水文及空间分析与SWMM融合协同在城市排水防涝领域中的应用
  • STM32项目分享:智能洗碗机
  • Java并发编程:JUC核心组件全解析
  • 牛客NC16625 [NOIP2009]分数线划定(排序)
  • 矿用支架电液控配件3针3孔钢丝编织橡胶护套连接器
  • 基于深度学习的语音情感识别系统的设计与实现【BiLSTM、多层感知神经网络、Emotion2Vec、与CustomFeature】
  • 对随机生成的html文件做标签简析
  • RPA软件机器人如何提高工作效率?
  • python 中什么是作用域(Scope)?为什么函数内部的变量在外部无法访问?
  • 【华为机试】547. 省份数量
  • How script location, CWD, and relative imports interact
  • TIM定时中断
  • 操作系统:系统调用(System Calls)