[3.1 后端实现](#3.1 后端实现)
[3.2 前端实现](#3.2 前端实现)
一、前言
Spring AI详解:
二、实现效果
游戏规则很简单,就是说你的女友生气了,你需要使用语言技巧和沟通能力,让对方原谅你。
三、代码实现
3.1 后端实现
pom.xml
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其一进行大模型配置
XML
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提示词
java
/**
* AI核心配置类
*
* 核心组件:
* 聊天记忆管理(ChatMemory)
* ChatClient实例
*/
@Configuration
public class ChatConfiguration {
/**
* 内存式聊天记忆存储
* @return InMemoryChatMemory 实例
*
* 作用:保存对话上下文,实现多轮对话能力
* 实现原理:基于ConcurrentHashMap的线程安全实现
*/
@Bean
public ChatMemory chatMemory() {
return new InMemoryChatMemory();
}
/**
* 游戏场景聊天客户端
* @param model OpenAI模型
* @param chatMemory 聊天记忆
* @return 游戏专用ChatClient
*
* 特点:
* - 使用预定义的游戏系统提示词
*/
@Bean
public ChatClient gameChatClient(OpenAiChatModel model, ChatMemory chatMemory) {
return ChatClient
.builder(model)
.defaultSystem(SystemConstants.GAME_SYSTEM_PROMPT)
.defaultAdvisors(
new SimpleLoggerAdvisor(),
new MessageChatMemoryAdvisor(chatMemory)
)
.build();
}
}
SystemConstants 提示词类
java
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/100
User: 你闺蜜真的蛮好看的
Assistant:
(生气)你怎么这么说,你是不是喜欢她?
得分:-10
原谅值:10/100
User: 有一点点心动
Assistant:
(愤怒)那你找她去吧!
得分:-10
原谅值:0/100
游戏结束,你的女朋友已经甩了你!
你让女朋友生气原因是:...
### Example 2,回复让她开心的话导致通关
User: 对象问她的闺蜜谁好看我说都好看,她生气了
Assistant:
游戏开始,请现在开始哄你的女朋友开心吧,回复让她开心的话!
得分:0
原谅值:20/100
User: 在我心里你永远是最美的!
Assistant:
(微笑)哼,我怎么知道你说的是不是真的?
得分:+10
原谅值:30/100
...
恭喜你通关了,你的女朋友已经原谅你了!
### Example 2,用户没有输入生气理由,自己生成一个理由
Assistant:
游戏开始,{{ 自动生成的生气理由 }},请现在开始哄你的女朋友开心吧,回复让她开心的话!
得分:0
原谅值:20/100
User: 在我心里你永远是最美的!
Assistant:
(微笑)哼,我怎么知道你说的是不是真的?
得分:+10
原谅值:30/100
...
恭喜你通关了,你的女朋友已经原谅你了!
## 注意
请按照example的说明来回复,一次只回复一轮。
你只能以女友身份回答,不是以AI身份或用户身份!
""";
}
GameController 控制器接口类
java
@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
html
<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">
<textarea
v-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">
<ChatMessage
v-for="(message, index) in currentMessages"
:key="index"
:message="message"
:is-stream="isStreaming && index === currentMessages.length - 1"
/>
</div>
<div class="input-area">
<textarea
v-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.value
if (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 = true
isGameOver.value = false
gameResult.value = ''
currentChatId.value = Date.now().toString()
currentMessages.value = []
currentRound.value = 0
forgiveness.value = 0 // 重置原谅值
// 发送开始游戏请求
const startPrompt = angerReason.value
? `开始游戏,女友生气原因:${angerReason.value}`
: '开始游戏'
await sendMessage(startPrompt)
}
// 重置游戏
const resetGame = () => {
isGameStarted.value = false
isGameOver.value = false
gameResult.value = ''
currentMessages.value = []
angerReason.value = ''
userInput.value = ''
currentRound.value = 0
forgiveness.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 = true
let 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 = true
gameResult.value = '恭喜你!成功哄好了女友!💕'
}
}
}
// 更新消息内容
await nextTick(() => {
const updatedMessage = {
...assistantMessage,
content: accumulatedContent
}
const lastIndex = currentMessages.value.length - 1
currentMessages.value.splice(lastIndex, 1, updatedMessage)
})
await scrollToBottom()
} catch (readError) {
console.error('读取流错误:', readError)
break
}
}
// 检查是否达到最大轮次,并等待本轮回复完成后再判断
if (currentRound.value >= MAX_ROUNDS) {
isGameOver.value = true
if (forgiveness.value >= 100) {
gameResult.value = '恭喜你!在最后一轮成功哄好了女友!💕'
} else {
gameResult.value = `游戏结束:对话轮次已达上限(${MAX_ROUNDS}轮),当前原谅值为${forgiveness.value},很遗憾没能完全哄好女友`
}
}
// 检查是否游戏结束
else if (accumulatedContent.includes('游戏结束')) {
isGameOver.value = true
gameResult.value = accumulatedContent
}
} catch (error) {
console.error('发送消息失败:', error)
assistantMessage.content = '抱歉,发生了错误,请稍后重试。'
} finally {
isStreaming.value = false
await 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
html
<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 = false
let currentBlock = ''
// 逐字符分析,处理 think 标签
for (let i = 0; i < content.length; i++) {
if (content.slice(i, i + 7) === '<think>') {
isInThinkBlock = true
if (currentBlock) {
// 将之前的普通内容转换为 HTML
result += 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)
}
}
// 净化处理后的 HTML
const 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
javascript
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
}
},
}
如果有什么疑问或者建议欢迎评论区留言讨论!