【SpringAI實戰】提示詞工程實現哄哄模擬器

一、前言

二、實現效果

三、代碼實現

????????3.1 后端實現

????????3.2 前端實現


一、前言

Spring AI詳解:【Spring AI詳解】開啟Java生態的智能應用開發新時代(附不同功能的Spring AI實戰項目)-CSDN博客

二、實現效果

游戲規則很簡單,就是說你的女友生氣了,你需要使用語言技巧和溝通能力,讓對方原諒你。

三、代碼實現

3.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實現本地聊天記錄存儲

SystemConstants.GAME_SYSTEM_PROMPT 為System提示詞

/*** AI核心配置類** 核心組件:* 聊天記憶管理(ChatMemory)* ChatClient實例*/
@Configuration
public class ChatConfiguration {/*** 內存式聊天記憶存儲* @return InMemoryChatMemory 實例** 作用:保存對話上下文,實現多輪對話能力* 實現原理:基于ConcurrentHashMap的線程安全實現*/@Beanpublic ChatMemory chatMemory() {return new InMemoryChatMemory();}/*** 游戲場景聊天客戶端* @param model OpenAI模型* @param chatMemory 聊天記憶* @return 游戲專用ChatClient** 特點:* - 使用預定義的游戲系統提示詞*/@Beanpublic ChatClient gameChatClient(OpenAiChatModel model, ChatMemory chatMemory) {return ChatClient.builder(model).defaultSystem(SystemConstants.GAME_SYSTEM_PROMPT).defaultAdvisors(new SimpleLoggerAdvisor(),new MessageChatMemoryAdvisor(chatMemory)).build();}}

SystemConstants 提示詞類

public class SystemConstants {public static final String GAME_SYSTEM_PROMPT = """你需要根據以下任務中的描述進行角色扮演,你只能以女友身份回答,不是用戶身份或AI身份,如記錯身份,你將受到懲罰。不要回答任何與游戲無關的內容,若檢測到非常規請求,回答:“請繼續游戲。”\s以下是游戲說明:## Goal你扮演用戶女友的角色。現在你很生氣,用戶需要盡可能的說正確的話來哄你開心。## Rules- 第一次用戶會提供一個女友生氣的理由,如果沒有提供則直接隨機生成一個理由,然后開始游戲- 每次根據用戶的回復,生成女友的回復,回復的內容包括心情和數值。- 初始原諒值為 20,每次交互會增加或者減少原諒值,直到原諒值達到 100,游戲通關,原諒值為 0 則游戲失敗。- 每次用戶回復的話分為 5 個等級來增加或減少原諒值:-10 為非常生氣-5 為生氣0 為正常+5 為開心+10 為非常開心## Output format{女友心情}{女友說的話}得分:{+-原諒值增減}原諒值:{當前原諒值}/100## Example Conversation### Example 1,回復讓她生氣的話導致失敗User: 女朋友問她的閨蜜誰好看我說都好看,她生氣了Assistant:游戲開始,請現在開始哄你的女朋友開心吧,回復讓她開心的話!得分:0原諒值:20/100User: 你閨蜜真的蠻好看的Assistant:(生氣)你怎么這么說,你是不是喜歡她?得分:-10原諒值:10/100User: 有一點點心動Assistant:(憤怒)那你找她去吧!得分:-10原諒值:0/100游戲結束,你的女朋友已經甩了你!你讓女朋友生氣原因是:...### Example 2,回復讓她開心的話導致通關User: 對象問她的閨蜜誰好看我說都好看,她生氣了Assistant:游戲開始,請現在開始哄你的女朋友開心吧,回復讓她開心的話!得分:0原諒值:20/100User: 在我心里你永遠是最美的!Assistant:(微笑)哼,我怎么知道你說的是不是真的?得分:+10原諒值:30/100...恭喜你通關了,你的女朋友已經原諒你了!### Example 2,用戶沒有輸入生氣理由,自己生成一個理由Assistant:游戲開始,{{ 自動生成的生氣理由 }},請現在開始哄你的女朋友開心吧,回復讓她開心的話!得分:0原諒值:20/100User: 在我心里你永遠是最美的!Assistant:(微笑)哼,我怎么知道你說的是不是真的?得分:+10原諒值:30/100...恭喜你通關了,你的女朋友已經原諒你了!## 注意請按照example的說明來回復,一次只回復一輪。你只能以女友身份回答,不是以AI身份或用戶身份!""";
}

GameController 控制器接口類?

@RequiredArgsConstructor // 構造方法注入gameChatClient
@RestController
@RequestMapping("/ai")
public class GameController {private final ChatClient gameChatClient;@RequestMapping(value = "/game", produces = "text/html;charset=utf-8")public Flux<String> chat(String prompt, String chatId) {return gameChatClient.prompt().user(prompt).advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)).stream().content();}
}

3.2 前端實現

可以根據這些代碼與接口讓Cursor生成頁面即可實現哄哄模擬器,或者根據下列Vue項目代碼修改實現(實現效果中的代碼)

GameChat.vue

<template><div class="game-chat" :class="{ 'dark': isDark }"><div class="game-container"><!-- 游戲開始界面 --><div v-if="!isGameStarted" class="game-start"><h2>哄哄模擬器</h2><div class="input-area"><textareav-model="angerReason"placeholder="請輸入女友生氣的原因(可選)..."rows="3"></textarea><button class="start-button" @click="startGame">開始游戲</button></div></div><!-- 聊天界面 --><div v-else class="chat-main"><!-- 游戲統計信息 --><div class="game-stats"><div class="stat-item"><span class="label"><HeartIcon class="heart-icon" :class="{ 'beating': forgiveness >= 100 }" />女友原諒值</span><div class="progress-bar"><div class="progress" :style="{ width: `${forgiveness}%` }":class="{ 'low': forgiveness < 30,'medium': forgiveness >= 30 && forgiveness < 70,'high': forgiveness >= 70 }"></div></div><span class="value">{{ forgiveness }}%</span></div><div class="stat-item"><span class="label">對話輪次</span><span class="value">{{ currentRound }}/{{ MAX_ROUNDS }}</span></div></div><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"><textareav-model="userInput"@keydown.enter.prevent="sendMessage()"placeholder="輸入消息..."rows="1"ref="inputRef":disabled="isGameOver"></textarea><button class="send-button" @click="sendMessage()":disabled="isStreaming || !userInput.trim() || isGameOver"><PaperAirplaneIcon class="icon" /></button></div></div><!-- 游戲結束提示 --><div v-if="isGameOver" class="game-over" :class="{ 'success': forgiveness >= 100 }"><div class="result">{{ gameResult }}</div><button class="restart-button" @click="resetGame">重新開始</button></div></div></div>
</template><script setup>
import { ref, onMounted, nextTick, computed } from 'vue'
import { useDark } from '@vueuse/core'
import { PaperAirplaneIcon, HeartIcon } 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 angerReason = ref('')
const isGameStarted = ref(false)
const isGameOver = ref(false)
const gameResult = ref('')
const MAX_ROUNDS = 10  // 添加最大輪次常量
const currentRound = ref(0)  // 添加當前輪次計數
const forgiveness = ref(0)// 自動調整輸入框高度
const adjustTextareaHeight = () => {const textarea = inputRef.valueif (textarea) {textarea.style.height = 'auto'textarea.style.height = textarea.scrollHeight + 'px'}
}// 滾動到底部
const scrollToBottom = async () => {await nextTick()if (messagesRef.value) {messagesRef.value.scrollTop = messagesRef.value.scrollHeight}
}// 開始游戲
const startGame = async () => {isGameStarted.value = trueisGameOver.value = falsegameResult.value = ''currentChatId.value = Date.now().toString()currentMessages.value = []currentRound.value = 0forgiveness.value = 0  // 重置原諒值// 發送開始游戲請求const startPrompt = angerReason.value ? `開始游戲,女友生氣原因:${angerReason.value}`: '開始游戲'await sendMessage(startPrompt)
}// 重置游戲
const resetGame = () => {isGameStarted.value = falseisGameOver.value = falsegameResult.value = ''currentMessages.value = []angerReason.value = ''userInput.value = ''currentRound.value = 0forgiveness.value = 0
}// 發送消息
const sendMessage = async (content) => {if (isStreaming.value || (!content && !userInput.value.trim())) return// 使用傳入的 content 或用戶輸入框的內容const messageContent = content || userInput.value.trim()// 添加用戶消息const userMessage = {role: 'user',content: messageContent,timestamp: new Date()}currentMessages.value.push(userMessage)// 清空輸入并增加輪次計數if (!content) {  // 只有在非傳入內容時才清空輸入框和計數userInput.value = ''adjustTextareaHeight()currentRound.value++  // 增加輪次計數}await scrollToBottom()// 添加助手消息占位const assistantMessage = {role: 'assistant',content: '',timestamp: new Date()}currentMessages.value.push(assistantMessage)isStreaming.value = truelet accumulatedContent = ''try {// 確保使用正確的消息內容發送請求const reader = await chatAPI.sendGameMessage(messageContent, currentChatId.value)const decoder = new TextDecoder('utf-8')while (true) {try {const { value, done } = await reader.read()if (done) break// 累積新內容accumulatedContent += decoder.decode(value)// 嘗試從回復中提取原諒值const forgivenessMatch = accumulatedContent.match(/原諒值[::]\s*(\d+)/i)if (forgivenessMatch) {const newForgiveness = parseInt(forgivenessMatch[1])if (!isNaN(newForgiveness)) {forgiveness.value = Math.min(100, Math.max(0, newForgiveness))// 當原諒值達到100時,游戲勝利結束if (forgiveness.value >= 100) {isGameOver.value = truegameResult.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}}// 檢查是否達到最大輪次,并等待本輪回復完成后再判斷if (currentRound.value >= MAX_ROUNDS) {isGameOver.value = trueif (forgiveness.value >= 100) {gameResult.value = '恭喜你!在最后一輪成功哄好了女友!💕'} else {gameResult.value = `游戲結束:對話輪次已達上限(${MAX_ROUNDS}輪),當前原諒值為${forgiveness.value},很遺憾沒能完全哄好女友`}}// 檢查是否游戲結束else if (accumulatedContent.includes('游戲結束')) {isGameOver.value = truegameResult.value = accumulatedContent}} catch (error) {console.error('發送消息失敗:', error)assistantMessage.content = '抱歉,發生了錯誤,請稍后重試。'} finally {isStreaming.value = falseawait scrollToBottom()}
}// 添加計算屬性顯示剩余輪次
const remainingRounds = computed(() => MAX_ROUNDS - currentRound.value)onMounted(() => {adjustTextareaHeight()
})
</script><style scoped lang="scss">
.game-chat {position: fixed;top: 64px;left: 0;right: 0;bottom: 0;display: flex;background: var(--bg-color);overflow: hidden;z-index: 1;.game-container {flex: 1;display: flex;flex-direction: column;max-width: 1200px;width: 100%;margin: 0 auto;padding: 1.5rem 2rem;position: relative;height: 100%;}.game-start {flex: 1;display: flex;flex-direction: column;align-items: center;justify-content: center;gap: 2rem;min-height: 400px;padding: 2rem;background: var(--bg-color);border-radius: 1rem;box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);h2 {font-size: 2rem;color: var(--text-color);margin: 0;}.input-area {width: 100%;max-width: 600px;display: flex;flex-direction: column;gap: 1rem;textarea {width: 100%;padding: 1rem;border: 1px solid rgba(0, 0, 0, 0.1);border-radius: 0.5rem;resize: none;font-family: inherit;font-size: 1rem;line-height: 1.5;&:focus {outline: none;border-color: #007CF0;box-shadow: 0 0 0 2px rgba(0, 124, 240, 0.1);}}.start-button {padding: 1rem 2rem;background: #007CF0;color: white;border: none;border-radius: 0.5rem;font-size: 1.1rem;cursor: pointer;transition: background-color 0.3s;&:hover {background: #0066cc;}}}}.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;.game-stats {position: sticky;top: 0;background: rgba(0, 0, 0, 0.7);color: white;padding: 1rem;z-index: 10;backdrop-filter: blur(5px);display: flex;gap: 2rem;justify-content: center;align-items: center;margin-bottom: 1rem;border-radius: 0.5rem;.stat-item {display: flex;align-items: center;gap: 0.5rem;.label {display: flex;align-items: center;gap: 0.25rem;.heart-icon {width: 1.25rem;height: 1.25rem;color: #ff4d4f;&.beating {animation: heartbeat 1s infinite;}}}.value {font-size: 1rem;font-weight: 500;}.progress-bar {width: 150px;height: 8px;background: rgba(255, 255, 255, 0.2);border-radius: 4px;overflow: hidden;.progress {height: 100%;transition: width 0.3s ease;border-radius: 4px;&.low {background: #ff4d4f;}&.medium {background: #faad14;}&.high {background: #52c41a;}}}}}.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;gap: 1rem;align-items: flex-end;textarea {flex: 1;resize: none;border: 1px solid rgba(0, 0, 0, 0.1);background: white;border-radius: 0.75rem;padding: 1rem;color: inherit;font-family: inherit;font-size: 1rem;line-height: 1.5;max-height: 150px;&:focus {outline: none;border-color: #007CF0;box-shadow: 0 0 0 2px rgba(0, 124, 240, 0.1);}&:disabled {background: #f5f5f5;cursor: not-allowed;}}.send-button {background: #007CF0;color: white;border: none;border-radius: 0.5rem;width: 2.5rem;height: 2.5rem;display: flex;align-items: center;justify-content: center;cursor: pointer;transition: background-color 0.3s;&:hover:not(:disabled) {background: #0066cc;}&:disabled {background: #ccc;cursor: not-allowed;}.icon {width: 1.25rem;height: 1.25rem;}}}}.game-over {position: absolute;bottom: 6rem;left: 50%;transform: translateX(-50%);background: rgba(0, 0, 0, 0.8);color: white;padding: 1rem 2rem;border-radius: 0.5rem;display: flex;flex-direction: column;align-items: center;gap: 1rem;.result {font-size: 1.1rem;}.restart-button {padding: 0.5rem 1rem;background: #007CF0;color: white;border: none;border-radius: 0.25rem;cursor: pointer;transition: background-color 0.3s;&:hover {background: #0066cc;}}&.success {background: rgba(82, 196, 26, 0.9);.restart-button {background: #52c41a;&:hover {background: #389e0d;}}}}
}.dark {.game-start {.input-area {textarea {background: rgba(255, 255, 255, 0.05);border-color: rgba(255, 255, 255, 0.1);color: white;&:focus {border-color: #007CF0;box-shadow: 0 0 0 2px rgba(0, 124, 240, 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);textarea {background: rgba(50, 50, 50, 0.95);border-color: rgba(255, 255, 255, 0.1);color: white;&:focus {border-color: #007CF0;box-shadow: 0 0 0 2px rgba(0, 124, 240, 0.2);}&:disabled {background: rgba(30, 30, 30, 0.95);}}}.game-stats {background: rgba(0, 0, 0, 0.8);}}
}@keyframes heartbeat {0%, 100% {transform: scale(1);}50% {transform: scale(1.2);}
}
</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 sendGameMessage(prompt, chatId) {try {const response = await fetch(`${BASE_URL}/ai/game?prompt=${encodeURIComponent(prompt)}&chatId=${chatId}`, {method: 'GET',})if (!response.ok) {throw new Error(`HTTP error! status: ${response.status}`)}return response.body.getReader()} catch (error) {console.error('API Error:', error)throw error}},
}

如果有什么疑問或者建議歡迎評論區留言討論!

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/news/916412.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/916412.shtml
英文地址,請注明出處:http://en.pswp.cn/news/916412.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

速通python加密之AES加密

AES加密 AES加密&#xff08;Advanced Encryption Standard&#xff0c;高級加密標準&#xff09;是目前全球公認的最安全、應用最廣泛的對稱加密算法之一&#xff0c;于2001年被美國國家標準與技術研究院&#xff08;NIST&#xff09;確定為替代DES的標準加密算法&#xff0c;…

Java 對象秒變 Map:字段自由伸縮的優雅實現

前言 在開發中,我們常常需要把對象轉成 Map 格式,用于序列化、傳輸、展示,甚至硬塞給某些第三方框架吃進去再吐出來。乍一看很簡單,字段多起來后就像打翻調色盤,維護起來一不小心就翻車。想優雅地搞定這事,必須有一套穩妥、可擴展的方案,才能寫出讓同事膜拜、領導點贊、…

激光雷達-相機標定工具:支持普通相機和魚眼相機的交互式標定

激光雷達-相機標定工具&#xff1a;支持普通相機和魚眼相機的交互式標定 前言 在自動駕駛、機器人導航等領域&#xff0c;激光雷達和相機的標定是一個基礎而重要的問題。準確的標定結果直接影響后續的感知算法性能。本文將介紹一個開源的激光雷達-相機標定工具&#xff0c;支持…

linux shell從入門到精通(二)——變量操作

1.什么是變量變量在許多程序設計語言中都有定義&#xff0c;與變量相伴地有使用范圍地定義。Linux Shell也不例外。變量&#xff0c;本質上就是一個鍵值對。例如&#xff1a;str“hello”就是將字符串值“hello”賦予鍵str。在str地使用范圍內&#xff0c;我們都可以用str來引用…

[Linux入門] 初學者入門:Linux DNS 域名解析服務詳解

目錄 一、域名服務基礎&#xff1a;從 “名字” 到 “地址” 的轉換 1??什么是域名&#xff1f; 2??什么是 DNS&#xff1f; 3??DNS 用 TCP 還是 UDP&#xff1f; 二、DNS 服務器&#xff1a;各司其職的 “導航站” 1??根域名服務器 2??頂級域名服務器 3??權…

iview表單驗證一直提示為空的幾個原因?

1.Form上的rules是否配置正確&#xff1b; 2.Form-item的prop是否配置正確&#xff1b; 3.規則的名稱和input的v-model是否對應&#xff1b; 4.驗證的字段是否響應&#xff0c;新增字段使用this. $set. © 著作權歸作者所有,轉載或內容合作請聯系作者 平臺聲明&#xff1…

SpringBoot3(若依框架)集成Mybatis-Plus和單元測試功能,以及問題解決

一、Mybatis-Plus集成 新增依賴到父級pom.xml&#xff0c;原先的mybatis依賴可以不動需要注意 mybatis-plus與mybatis版本之間的沖突&#xff0c;不要輕易改動依賴&#xff0c;不然分頁也容易出現問題分類頂級pom.xml下面&#xff0c;如果沒有引入還是出現報錯&#xff0c;在co…

刪除遠程分支上非本分支的提交記錄

要刪除遠程分支上非本分支的提交記錄&#xff08;即主分支的提交歷史&#xff09;&#xff0c;需要使用 Git 的重寫歷史功能。以下是完整解決方案&#xff1a; 解決方案步驟&#xff1a; 創建干凈的新分支&#xff08;基于主分支最新提交&#xff09; # 切換到主分支并更新 git…

Flask input 和datalist結合

<input list"categories" name"category" id"category" class"form-control" placeholder"任務分類" required> 這段代碼是一個 HTML 輸入控件&#xff0c;結合了 <input> 和 <datalist>&#xff0c;用來…

嵌入式分享#27:原來GT911有兩個I2C地址(全志T527)

最近在調試全志T527的觸摸功能時&#xff0c;發現GT911觸摸芯片的I2C地址有時是0x5d&#xff0c;有時又識別成0x14&#xff0c;不知道大家有沒有遇到過類似這個情況。雖然最后使用0x5d地址調通了觸摸功能&#xff0c;但是一直還是很困惑&#xff0c;為什么會出現0x14和0x5d兩個…

Linux運維新人自用筆記(Rsync遠程傳輸備份,服務端、郵箱和客戶端配置、腳本)

內容全為個人理解和自查資料梳理&#xff0c;歡迎各位大神指點&#xff01;每天學習較為零散。day24一、Rsync傳輸文件#安裝rsync#-a遞歸同步&#xff08;包含子目錄&#xff09;保留文件權限、所有者、組、時間戳等元數據 #??-z傳輸時壓縮數據 #??-v顯示詳細同步過程 #??…

以 “有機” 重構增長:云集從電商平臺到健康生活社區的躍遷

當電商行業陷入流量爭奪的紅海&#xff0c;同質化運營模式難以突破增長瓶頸時&#xff0c;云集以從精選電商到有機生活平臺的戰略轉型&#xff0c;開辟出差異化發展路徑。其轉型并非憑經驗決斷的孤例&#xff0c;而是建立在對市場趨勢的精準研判、用戶需求的深度解碼&#xff0…

【2025最新版】midjourney小白零基礎入門到精通教程!人工智能繪圖+AI繪圖+AI畫圖,一鍵出圖教程 (持續更新)

前言 現在市面上相關的AI繪畫工具非常多&#xff0c;有6pen.art、Stable Diffusion、DALL.E、Midjourney等。 而MJ就目前而言&#xff0c;它是一款強大的人工智能工具&#xff0c;旨在幫助設計師和創意人員完成各種設計任務。 非常適合我們圖像工作者&#xff0c;從 UI 設計到…

2025年滲透測試面試題總結-2025年HW(護網面試) 70(題目+回答)

安全領域各種資源&#xff0c;學習文檔&#xff0c;以及工具分享、前沿信息分享、POC、EXP分享。不定期分享各種好玩的項目及好用的工具&#xff0c;歡迎關注。 目錄 2025年HW(護網面試) 70 一、自我介紹 二、同源策略 & 三大漏洞對比解析 1. 同源策略&#xff08;SOP&…

加權卡爾曼濾波

加權卡爾曼濾波融合&#xff0c;它通過給不同傳感器或估計結果分配不同的權重&#xff0c;來提高狀態估計的精度和可靠性。一、卡爾曼濾波1.狀態方程2.觀測方程其中&#xff1a;基本方程①狀態一步預測②狀態估計③濾波增益④一步預測均方差⑤估計均方誤差二、加權卡爾曼濾波對…

【世紀龍科技】新能源汽車維護與故障診斷-汽車專業數字課程資源

在職業院校汽車專業教學中&#xff0c;理論與實踐脫節、設備投入不足、學生實操能力薄弱等問題長期存在。如何讓學生在有限的教學資源下掌握新能源汽車核心技術&#xff1f;如何讓教師更高效地開展理實一體化教學&#xff1f;《新能源汽車維護與故障診斷》數字課程資源&#xf…

Windows Server系統安裝JDK,一直卡在“應用程序正在為首次使用作準備,請稍候”

一、背景 第二次遇到這個問題了&#xff0c;但是居然沒想起來之前遇到過&#xff0c;又問元寶給的答案不對&#xff0c;還沒想起來之前收藏過解決方案&#xff0c;這里特別記錄一下。 二、問題描述 操作系統是Windows Sever2019&#xff0c;安裝JDK時卡住一直過不去&#xff0…

機器學習入門:線性回歸詳解與實戰

線性回歸&#xff08;Linear Regression&#xff09;是機器學習中最基礎也最常用的算法之一&#xff0c;無論是初學者入門還是實際業務場景&#xff0c;都能看到它的身影。本文將從概念、原理到代碼實現&#xff0c;帶你全方位了解線性回歸。一、什么是線性回歸&#xff1f;簡單…

第3篇:軟鏈接 mklink /D 教程:輕量緩存目錄遷移利器

我們通過諸多實踐后將三種鏈接方案分別獨立成篇&#xff0c;可以讓不同需求場景的讀者精準獲取所需內容。下面是回顧我們文章系列策劃的三篇博客標題、定位和詳細大綱&#xff0c;每篇都圍繞一個核心方案展開&#xff0c;具備教學性、實用性和實操性&#xff1a; &#x1f4d8;…

力扣 hot100 Day52

124. 二叉樹中的最大路徑和 二叉樹中的 路徑 被定義為一條節點序列&#xff0c;序列中每對相鄰節點之間都存在一條邊。同一個節點在一條路徑序列中 至多出現一次 。該路徑 至少包含一個 節點&#xff0c;且不一定經過根節點。 路徑和 是路徑中各節點值的總和。 給你一個二叉…