秦皇岛网站推广排名,企业信息港网站建没,网站后台目录如何保护,sem是什么意思?一、前言
二、实现效果
三、代码实现 3.1 后端实现 3.2 前端实现 一、前言
Spring AI详解#xff1a;【Spring AI详解】开启Java生态的智能应用开发新时代(附不同功能的Spring AI实战项目)-CSDN博客
二、实现效果
游戏规则很简单#xff0c;就是说你的女友生气了#x…一、前言
二、实现效果
三、代码实现 3.1 后端实现 3.2 前端实现 一、前言
Spring AI详解【Spring AI详解】开启Java生态的智能应用开发新时代(附不同功能的Spring AI实战项目)-CSDN博客
二、实现效果
游戏规则很简单就是说你的女友生气了你需要使用语言技巧和沟通能力让对方原谅你。 三、代码实现
3.1 后端实现
pom.xml !-- 继承Spring Boot父POM提供默认依赖管理 --parentgroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-parent/artifactIdversion3.4.3/version !-- Spring Boot版本 --relativePath/ !-- 优先从本地仓库查找 --/parent!-- 自定义属性 --propertiesjava.version17/java.version !-- JDK版本要求 --spring-ai.version1.0.0-M6/spring-ai.version !-- Spring AI里程碑版本 --/properties!-- 项目依赖 --dependencies!-- Spring Boot Web支持 --dependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-web/artifactId/dependency!-- AI相关依赖 --dependencygroupIdorg.springframework.ai/groupIdartifactIdspring-ai-ollama-spring-boot-starter/artifactId !-- Ollama集成 --/dependencydependencygroupIdorg.springframework.ai/groupIdartifactIdspring-ai-openai-spring-boot-starter/artifactId !-- OpenAI集成 --/dependency!-- 开发工具 --dependencygroupIdorg.projectlombok/groupIdartifactIdlombok/artifactIdversion1.18.22/version !-- 注解简化代码 --scopeprovided/scope !-- 编译期使用 --/dependency/dependencies!-- 依赖管理统一Spring AI家族版本 --dependencyManagementdependenciesdependencygroupIdorg.springframework.ai/groupIdartifactIdspring-ai-bom/artifactIdversion${spring-ai.version}/versiontypepom/typescopeimport/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服务地址默认端口11434chat: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;charsetutf-8)public FluxString 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
templatediv classgame-chat :class{ dark: isDark }div classgame-container!-- 游戏开始界面 --div v-if!isGameStarted classgame-starth2哄哄模拟器/h2div classinput-areatextareav-modelangerReasonplaceholder请输入女友生气的原因可选...rows3/textareabutton classstart-button clickstartGame开始游戏/button/div/div!-- 聊天界面 --div v-else classchat-main!-- 游戏统计信息 --div classgame-statsdiv classstat-itemspan classlabelHeartIcon classheart-icon :class{ beating: forgiveness 100 } /女友原谅值/spandiv classprogress-bardiv classprogress :style{ width: ${forgiveness}% }:class{ low: forgiveness 30,medium: forgiveness 30 forgiveness 70,high: forgiveness 70 }/div/divspan classvalue{{ forgiveness }}%/span/divdiv classstat-itemspan classlabel对话轮次/spanspan classvalue{{ currentRound }}/{{ MAX_ROUNDS }}/span/div/divdiv classmessages refmessagesRefChatMessagev-for(message, index) in currentMessages:keyindex:messagemessage:is-streamisStreaming index currentMessages.length - 1//divdiv classinput-areatextareav-modeluserInputkeydown.enter.preventsendMessage()placeholder输入消息...rows1refinputRef:disabledisGameOver/textareabutton classsend-button clicksendMessage():disabledisStreaming || !userInput.trim() || isGameOverPaperAirplaneIcon classicon //button/div/div!-- 游戏结束提示 --div v-ifisGameOver classgame-over :class{ success: forgiveness 100 }div classresult{{ gameResult }}/divbutton classrestart-button clickresetGame重新开始/button/div/div/div
/templatescript 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/apiconst 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 autotextarea.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()
})
/scriptstyle scoped langscss
.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
templatediv classmessage :class{ message-user: isUser }div classavatarUserCircleIcon v-ifisUser classicon /ComputerDesktopIcon v-else classicon :class{ assistant: !isUser } //divdiv classcontentdiv classtext-containerbutton v-ifisUser classuser-copy-button clickcopyContent :titlecopyButtonTitleDocumentDuplicateIcon v-if!copied classcopy-icon /CheckIcon v-else classcopy-icon copied //buttondiv classtext refcontentRef v-ifisUser{{ message.content }}/divdiv classtext markdown-content refcontentRef v-else v-htmlprocessedContent/div/divdiv classmessage-footer v-if!isUserbutton classcopy-button clickcopyContent :titlecopyButtonTitleDocumentDuplicateIcon v-if!copied classcopy-icon /CheckIcon v-else classcopy-icon copied //button/div/div/div
/templatescript 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.cssconst 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 // 跳过 thinkcontinue}if (content.slice(i, i 8) /think) {isInThinkBlock false// 将 think 块包装在特殊 div 中result div classthink-block${marked.parse(currentBlock)}/divcurrentBlock i 7 // 跳过 /thinkcontinue}currentBlock content[i]}// 处理剩余内容if (currentBlock) {if (isInThinkBlock) {result div classthink-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-buttoncopyBtn.title 复制代码copyBtn.innerHTML svg xmlnshttp://www.w3.org/2000/svg classcode-copy-icon fillnone viewBox0 0 24 24 strokecurrentColorpath stroke-linecapround stroke-linejoinround stroke-width2 dM8 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-messagesuccessMsg.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()
}
/scriptstyle scoped langscss
.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:8080export 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}},
}
如果有什么疑问或者建议欢迎评论区留言讨论