|
@@ -0,0 +1,536 @@
|
|
|
|
+<template>
|
|
|
|
+ <div class="chat-container">
|
|
|
|
+ <div class="chat-messages" ref="messageBox">
|
|
|
|
+ <div v-for="(message, index) in chatHistory"
|
|
|
|
+ :key="index"
|
|
|
|
+ :class="['message', message.type]">
|
|
|
|
+ <div class="message-content">
|
|
|
|
+ <div v-if="isLoading && message === currentStreamingMessage && message.type === 'ai'" class="message-loading">
|
|
|
|
+ <i class="el-icon-loading"></i>
|
|
|
|
+ </div>
|
|
|
|
+ <div v-if="message.type === 'ai'" class="message-actions">
|
|
|
|
+ <button class="action-btn" @click="copyMessage(message.content)" title="复制">
|
|
|
|
+ <i class="fas fa-copy"></i>
|
|
|
|
+ </button>
|
|
|
|
+ <button v-if="message === currentStreamingMessage"
|
|
|
|
+ class="action-btn"
|
|
|
|
+ @click="stopGeneration"
|
|
|
|
+ title="停止生成">
|
|
|
|
+ <i class="fas fa-stop"></i>
|
|
|
|
+ </button>
|
|
|
|
+ <button class="action-btn"
|
|
|
|
+ @click="regenerateResponse(index)"
|
|
|
|
+ title="重新生成"
|
|
|
|
+ :disabled="isLoading">
|
|
|
|
+ <i class="fas fa-redo"></i>
|
|
|
|
+ </button>
|
|
|
|
+ </div>
|
|
|
|
+ <div class="content-text">{{ message.isTyping ? message.displayContent : message.content }}</div>
|
|
|
|
+ <div class="message-time">{{ message.timestamp }}</div>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <div class="input-area">
|
|
|
|
+ <!-- <div class="typing-status" v-if="isLoading">AI正在思考中...</div> -->
|
|
|
|
+ <select v-model="selectedQuestion" class="preset-questions">
|
|
|
|
+ <option value="">选择预设问题...</option>
|
|
|
|
+ <option v-for="(q, index) in presetQuestions"
|
|
|
|
+ :key="index"
|
|
|
|
+ :value="q.question">
|
|
|
|
+ {{ q.question }}
|
|
|
|
+ </option>
|
|
|
|
+ </select>
|
|
|
|
+
|
|
|
|
+ <div class="input-group">
|
|
|
|
+ <input type="text"
|
|
|
|
+ v-model="userInput"
|
|
|
|
+ @keyup.enter="sendMessage"
|
|
|
|
+ placeholder="输入您的问题...">
|
|
|
|
+ <button @click="sendMessage" :disabled="isLoading">发送</button>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+</template>
|
|
|
|
+
|
|
|
|
+<script>
|
|
|
|
+import axios from 'axios'
|
|
|
|
+
|
|
|
|
+export default {
|
|
|
|
+ name: 'AIChatBox',
|
|
|
|
+ data() {
|
|
|
|
+ return {
|
|
|
|
+ userInput: '',
|
|
|
|
+ selectedQuestion: '',
|
|
|
|
+ chatHistory: [],
|
|
|
|
+ presetQuestions: [
|
|
|
|
+ {
|
|
|
|
+ question: "你好,请问你是谁?",
|
|
|
|
+ },
|
|
|
|
+ {
|
|
|
|
+ question: "现在几点了?",
|
|
|
|
+ },
|
|
|
|
+ {
|
|
|
|
+ question: "你能做什么?",
|
|
|
|
+ },
|
|
|
|
+ {
|
|
|
|
+ question: "今天天气怎么样?",
|
|
|
|
+ }
|
|
|
|
+ ],
|
|
|
|
+ isLoading: false,
|
|
|
|
+ currentStreamingMessage: null,
|
|
|
|
+ controller: null, // 用于中止请求
|
|
|
|
+ typingSpeed: 20, // 打字机效果的速度(毫秒)
|
|
|
|
+ scrollLock:true,
|
|
|
|
+ }
|
|
|
|
+ },
|
|
|
|
+
|
|
|
|
+ created() {
|
|
|
|
+ // 从 localStorage 加载历史记录
|
|
|
|
+ const savedHistory = localStorage.getItem('chatHistory')
|
|
|
|
+ if (savedHistory) {
|
|
|
|
+ this.chatHistory = JSON.parse(savedHistory)
|
|
|
|
+ }
|
|
|
|
+ },
|
|
|
|
+
|
|
|
|
+ watch: {
|
|
|
|
+ chatHistory: {
|
|
|
|
+ handler(newVal) {
|
|
|
|
+ // 保存到 localStorage
|
|
|
|
+ localStorage.setItem('chatHistory', JSON.stringify(newVal))
|
|
|
|
+ },
|
|
|
|
+ deep: true
|
|
|
|
+ },
|
|
|
|
+ selectedQuestion(val) {
|
|
|
|
+ if (val) {
|
|
|
|
+ this.userInput = val;
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ },
|
|
|
|
+ mounted() {
|
|
|
|
+ // 添加滚动事件监听
|
|
|
|
+ const messageBox = this.$refs.messageBox;
|
|
|
|
+ if (messageBox) {
|
|
|
|
+ messageBox.addEventListener('scroll', this.handleScroll);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 初始滚动到底部
|
|
|
|
+ this.scrollToBottom();
|
|
|
|
+ },
|
|
|
|
+
|
|
|
|
+ beforeDestroy() {
|
|
|
|
+ // 移除滚动事件监听
|
|
|
|
+ const messageBox = this.$refs.messageBox;
|
|
|
|
+ if (messageBox) {
|
|
|
|
+ messageBox.removeEventListener('scroll', this.handleScroll);
|
|
|
|
+ }
|
|
|
|
+ },
|
|
|
|
+
|
|
|
|
+ updated() {
|
|
|
|
+ // 当内容更新时滚动到底部
|
|
|
|
+ this.scrollToBottom();
|
|
|
|
+ },
|
|
|
|
+
|
|
|
|
+ methods: {
|
|
|
|
+ //发送信息
|
|
|
|
+ async sendMessage() {
|
|
|
|
+ if (!this.userInput.trim()) return;
|
|
|
|
+
|
|
|
|
+ const userMessage = {
|
|
|
|
+ type: 'user',
|
|
|
|
+ content: this.userInput,
|
|
|
|
+ timestamp: new Date().toLocaleTimeString()
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ this.chatHistory.push(userMessage);
|
|
|
|
+ this.isLoading = true;
|
|
|
|
+
|
|
|
|
+ const aiMessage = {
|
|
|
|
+ type: 'ai',
|
|
|
|
+ content: '',
|
|
|
|
+ displayContent: '', // 用于打字机效果
|
|
|
|
+ isTyping: true,
|
|
|
|
+ timestamp: new Date().toLocaleTimeString()
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ this.chatHistory.push(aiMessage);
|
|
|
|
+ this.currentStreamingMessage = aiMessage;
|
|
|
|
+
|
|
|
|
+ try {
|
|
|
|
+ await this.getAIResponse(this.userInput);
|
|
|
|
+ } catch (error) {
|
|
|
|
+ if (error.name === 'AbortError') {
|
|
|
|
+ this.currentStreamingMessage.content += '\n[生成已停止]';
|
|
|
|
+ } else {
|
|
|
|
+ console.error('AI响应错误:', error);
|
|
|
|
+ this.currentStreamingMessage.content = '抱歉,服务出现了问题,请稍后再试。';
|
|
|
|
+ }
|
|
|
|
+ } finally {
|
|
|
|
+ this.isLoading = false;
|
|
|
|
+ this.userInput = '';
|
|
|
|
+ this.selectedQuestion = '';
|
|
|
|
+ await this.finishTypingEffect(this.currentStreamingMessage);
|
|
|
|
+ this.currentStreamingMessage = null;
|
|
|
|
+ this.controller = null;
|
|
|
|
+ this.$nextTick(() => {
|
|
|
|
+ this.scrollToBottom();
|
|
|
|
+ });
|
|
|
|
+ }
|
|
|
|
+ },
|
|
|
|
+ //获取回复信息
|
|
|
|
+ async getAIResponse(message) {
|
|
|
|
+ this.controller = new AbortController();
|
|
|
|
+
|
|
|
|
+ try {
|
|
|
|
+ const response = await fetch('/api/AI/chat', {
|
|
|
|
+ method: 'POST',
|
|
|
|
+ headers: {
|
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
|
+ 'Accept': 'text/event-stream',
|
|
|
|
+ },
|
|
|
|
+ body: JSON.stringify({
|
|
|
|
+ messages: [{
|
|
|
|
+ role: 'user',
|
|
|
|
+ content: message
|
|
|
|
+ }],
|
|
|
|
+ // model: 'gpt-3.5-turbo',
|
|
|
|
+ stream: true,
|
|
|
|
+ }),
|
|
|
|
+ signal: this.controller.signal
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ if (!response.ok) throw new Error('AI API 调用失败');
|
|
|
|
+
|
|
|
|
+ const reader = response.body.getReader();
|
|
|
|
+ const decoder = new TextDecoder('utf-8');
|
|
|
|
+
|
|
|
|
+ while (true) {
|
|
|
|
+ const { done, value } = await reader.read();
|
|
|
|
+ if (done) break;
|
|
|
|
+
|
|
|
|
+ const chunk = decoder.decode(value);
|
|
|
|
+ const lines = chunk
|
|
|
|
+ .split('\n')
|
|
|
|
+ .filter(line => line.trim() !== '' && line.trim() !== 'data: [DONE]');
|
|
|
|
+
|
|
|
|
+ for (const line of lines) {
|
|
|
|
+ try {
|
|
|
|
+ const jsonStr = line.replace(/^data: /, '');
|
|
|
|
+ const json = JSON.parse(jsonStr);
|
|
|
|
+
|
|
|
|
+ if (json.choices[0]?.delta?.content) {
|
|
|
|
+ const newContent = json.choices[0].delta.content;
|
|
|
|
+ this.currentStreamingMessage.content += newContent;
|
|
|
|
+ await this.typeWriter(this.currentStreamingMessage, newContent);
|
|
|
|
+ }
|
|
|
|
+ } catch (e) {
|
|
|
|
+ console.error('解析响应数据出错:', e);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ } catch (error) {
|
|
|
|
+ throw error;
|
|
|
|
+ }
|
|
|
|
+ },
|
|
|
|
+
|
|
|
|
+ async typeWriter(message, newContent) {
|
|
|
|
+ const currentLength = message.displayContent.length;
|
|
|
|
+ const targetLength = message.content.length;
|
|
|
|
+
|
|
|
|
+ if (currentLength < targetLength) {
|
|
|
|
+ message.displayContent = message.content.substring(0, currentLength + newContent.length);
|
|
|
|
+ this.$nextTick(() => {
|
|
|
|
+ this.scrollToBottom();
|
|
|
|
+ });
|
|
|
|
+ }
|
|
|
|
+ },
|
|
|
|
+
|
|
|
|
+ async finishTypingEffect(message) {
|
|
|
|
+ message.displayContent = message.content;
|
|
|
|
+ message.isTyping = false;
|
|
|
|
+ },
|
|
|
|
+
|
|
|
|
+ //停止生成
|
|
|
|
+ stopGeneration() {
|
|
|
|
+ if (this.controller) {
|
|
|
|
+ this.controller.abort();
|
|
|
|
+ }
|
|
|
|
+ },
|
|
|
|
+
|
|
|
|
+ //重新生成
|
|
|
|
+ async regenerateResponse(index) {
|
|
|
|
+ const originalUserMessage = this.chatHistory[index - 1];
|
|
|
|
+ if (!originalUserMessage || originalUserMessage.type !== 'user') return;
|
|
|
|
+
|
|
|
|
+ // 删除当前回复
|
|
|
|
+ this.chatHistory.splice(index, 1);
|
|
|
|
+
|
|
|
|
+ // 重新发送请求
|
|
|
|
+ const aiMessage = {
|
|
|
|
+ type: 'ai',
|
|
|
|
+ content: '',
|
|
|
|
+ displayContent: '',
|
|
|
|
+ isTyping: true,
|
|
|
|
+ timestamp: new Date().toLocaleTimeString()
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ this.chatHistory.push(aiMessage);
|
|
|
|
+ this.currentStreamingMessage = aiMessage;
|
|
|
|
+ this.isLoading = true;
|
|
|
|
+
|
|
|
|
+ try {
|
|
|
|
+ await this.getAIResponse(originalUserMessage.content);
|
|
|
|
+ } catch (error) {
|
|
|
|
+ console.error('重新生成失败:', error);
|
|
|
|
+ this.currentStreamingMessage.content = '重新生成失败,请稍后再试。';
|
|
|
|
+ } finally {
|
|
|
|
+ this.isLoading = false;
|
|
|
|
+ await this.finishTypingEffect(this.currentStreamingMessage);
|
|
|
|
+ this.currentStreamingMessage = null;
|
|
|
|
+ }
|
|
|
|
+ },
|
|
|
|
+
|
|
|
|
+ //复制信息
|
|
|
|
+ async copyMessage(content) {
|
|
|
|
+ try {
|
|
|
|
+ await navigator.clipboard.writeText(content);
|
|
|
|
+ // 可以添加一个提示,表示复制成功
|
|
|
|
+ this.$message?.success?.('复制成功') || alert('复制成功');
|
|
|
|
+ } catch (err) {
|
|
|
|
+ console.error('复制失败:', err);
|
|
|
|
+ this.$message?.error?.('复制失败') || alert('复制失败');
|
|
|
|
+ }
|
|
|
|
+ },
|
|
|
|
+
|
|
|
|
+ //滚动到最底端
|
|
|
|
+ scrollToBottom() {
|
|
|
|
+ if (!this.scrollLock) return;
|
|
|
|
+
|
|
|
|
+ this.$nextTick(() => {
|
|
|
|
+ const messageBox = this.$refs.messageBox;
|
|
|
|
+ if (messageBox) {
|
|
|
|
+ // 使用平滑滚动效果
|
|
|
|
+ messageBox.scrollTo({
|
|
|
|
+ top: messageBox.scrollHeight,
|
|
|
|
+ behavior: 'instant'
|
|
|
|
+ });
|
|
|
|
+ }
|
|
|
|
+ });
|
|
|
|
+ },
|
|
|
|
+
|
|
|
|
+ // 监听滚动事件
|
|
|
|
+ handleScroll() {
|
|
|
|
+ const messageBox = this.$refs.messageBox;
|
|
|
|
+ if (!messageBox) return;
|
|
|
|
+
|
|
|
|
+ // 计算是否在底部附近(允许 100px 的误差)
|
|
|
|
+ const isNearBottom =
|
|
|
|
+ messageBox.scrollHeight - messageBox.scrollTop - messageBox.clientHeight < 100;
|
|
|
|
+
|
|
|
|
+ this.scrollLock = isNearBottom;
|
|
|
|
+ },
|
|
|
|
+
|
|
|
|
+ //清空历史
|
|
|
|
+ clearHistory() {
|
|
|
|
+ if (confirm('确定要清空聊天记录吗?')) {
|
|
|
|
+ this.chatHistory = [];
|
|
|
|
+ localStorage.removeItem('chatHistory');
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+</script>
|
|
|
|
+
|
|
|
|
+<style scoped>
|
|
|
|
+.chat-container {
|
|
|
|
+ width: 100%;
|
|
|
|
+ max-width: 600px;
|
|
|
|
+ margin: 0 auto;
|
|
|
|
+ border: 1px solid #ddd;
|
|
|
|
+ border-radius: 8px;
|
|
|
|
+ overflow: hidden;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.chat-messages {
|
|
|
|
+ height: 400px;
|
|
|
|
+ overflow-y: auto;
|
|
|
|
+ padding: 20px;
|
|
|
|
+ background: #f5f5f5;
|
|
|
|
+ scroll-behavior: smooth;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.message {
|
|
|
|
+ margin-bottom: 15px;
|
|
|
|
+ /* max-width: 80%; */
|
|
|
|
+ position:relative;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.message.user {
|
|
|
|
+ /* margin-left: auto; */
|
|
|
|
+ display: flex;
|
|
|
|
+ flex-direction: row-reverse;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.message-content {
|
|
|
|
+ padding: 10px 15px;
|
|
|
|
+ border-radius: 15px;
|
|
|
|
+ display: inline-block;
|
|
|
|
+ white-space: pre-wrap;
|
|
|
|
+ word-break: break-word;
|
|
|
|
+ position: relative;
|
|
|
|
+ padding-right: 30px; /* 为按钮留出空间 */
|
|
|
|
+ max-width: 80%;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.user .message-content {
|
|
|
|
+ background: #007bff;
|
|
|
|
+ color: white;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.ai .message-content {
|
|
|
|
+ background: white;
|
|
|
|
+ color: #333;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.input-area {
|
|
|
|
+ padding: 15px;
|
|
|
|
+ background: white;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.input-group {
|
|
|
|
+ display: flex;
|
|
|
|
+ margin-top: 10px;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+input {
|
|
|
|
+ flex: 1;
|
|
|
|
+ padding: 8px;
|
|
|
|
+ border: 1px solid #ddd;
|
|
|
|
+ border-radius: 4px;
|
|
|
|
+ margin-right: 10px;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+button {
|
|
|
|
+ padding: 8px 20px;
|
|
|
|
+ background: #007bff;
|
|
|
|
+ color: white;
|
|
|
|
+ border: none;
|
|
|
|
+ border-radius: 4px;
|
|
|
|
+ cursor: pointer;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+button:hover {
|
|
|
|
+ background: #0056b3;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.preset-questions {
|
|
|
|
+ width: 100%;
|
|
|
|
+ padding: 8px;
|
|
|
|
+ border: 1px solid #ddd;
|
|
|
|
+ border-radius: 4px;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.typing-status {
|
|
|
|
+ padding: 8px;
|
|
|
|
+ color: #666;
|
|
|
|
+ font-style: italic;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.message-time {
|
|
|
|
+ font-size: 0.8em;
|
|
|
|
+ color: #999;
|
|
|
|
+ margin-top: 4px;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+/* 添加一个清空历史记录的按钮样式 */
|
|
|
|
+.clear-history {
|
|
|
|
+ position: absolute;
|
|
|
|
+ top: 10px;
|
|
|
|
+ right: 10px;
|
|
|
|
+ padding: 5px 10px;
|
|
|
|
+ background: #dc3545;
|
|
|
|
+ color: white;
|
|
|
|
+ border: none;
|
|
|
|
+ border-radius: 4px;
|
|
|
|
+ cursor: pointer;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.message-loading{
|
|
|
|
+ position: absolute;
|
|
|
|
+ left: -20px;
|
|
|
|
+ top: 0px;
|
|
|
|
+ width: 20px;
|
|
|
|
+ height: 20px;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.message-actions {
|
|
|
|
+ position: absolute;
|
|
|
|
+ /* top: 5px; */
|
|
|
|
+ top: calc(100% + 5px);
|
|
|
|
+ /* right: 5px; */
|
|
|
|
+ display: flex;
|
|
|
|
+ gap: 5px;
|
|
|
|
+ opacity: 1;
|
|
|
|
+ transition: opacity 0.2s;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.message:hover .message-actions {
|
|
|
|
+ /* opacity: 1; */
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.action-btn {
|
|
|
|
+ padding: 4px 8px;
|
|
|
|
+ background: rgba(255, 255, 255, 0.9);
|
|
|
|
+ border: 1px solid #ddd;
|
|
|
|
+ border-radius: 4px;
|
|
|
|
+ cursor: pointer;
|
|
|
|
+ font-size: 12px;
|
|
|
|
+ color: #666;
|
|
|
|
+ transition: all 0.2s;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.action-btn:hover {
|
|
|
|
+ background: #f0f0f0;
|
|
|
|
+ color: #333;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.action-btn:disabled {
|
|
|
|
+ opacity: 0.5;
|
|
|
|
+ cursor: not-allowed;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.content-text {
|
|
|
|
+ white-space: pre-wrap;
|
|
|
|
+ word-break: break-word;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+/* 添加闪烁的光标效果 */
|
|
|
|
+.message.ai .content-text[data-typing="true"]::after {
|
|
|
|
+ content: '|';
|
|
|
|
+ animation: blink 1s infinite;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+@keyframes blink {
|
|
|
|
+ 0%, 100% { opacity: 1; }
|
|
|
|
+ 50% { opacity: 0; }
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+/* 自定义滚动条样式 */
|
|
|
|
+.chat-messages::-webkit-scrollbar {
|
|
|
|
+ width: 8px;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.chat-messages::-webkit-scrollbar-track {
|
|
|
|
+ background: #f1f1f1;
|
|
|
|
+ border-radius: 4px;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.chat-messages::-webkit-scrollbar-thumb {
|
|
|
|
+ background: #888;
|
|
|
|
+ border-radius: 4px;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.chat-messages::-webkit-scrollbar-thumb:hover {
|
|
|
|
+ background: #555;
|
|
|
|
+}
|
|
|
|
+</style>
|