vue实现模拟deepseekAI功能

如果是get可以考虑用sse,我当前是post,采用的是post长轮询实现

javascript 复制代码
   /**
       * 发送用户消息并与AI进行流式对话交互。
       * 该函数会创建一个机器人回复的消息对象,并将其推入消息列表中。
       * 随后通过fetch向后端发起聊天请求,支持SSE(Server-Sent Events)或普通JSON响应,
       * 并将返回的内容逐步追加到机器人的回复内容中,实现打字机效果。
       *
       * @param {string} message - 用户输入的原始消息内容
       */

代码实现

javascript 复制代码
<template>
  <div class="chat-dialog">
    <!-- 聊天消息区域 -->
    <div class="chat-messages" ref="messagesContainer">
      <div
        v-for="(message, index) in messages"
        :key="index"
        :class="['message', message.type]"
      >
        <!-- 机器人头像 -->
        <div v-if="message.type === 'bot'" class="avatar bot-avatar">
          <div class="robot-icon">🤖</div>
        </div>

        <!-- 消息内容 -->
        <div class="message-content">
          <div
            class="message-bubble"
            :class="[
              message.type,
              {
                loading:
                  isLoading &&
                  message.type === 'bot' &&
                  !(message.content || message.displayContent)
              }
            ]"
          >
            <div
              v-if="
                isLoading &&
                message.type === 'bot' &&
                !(message.content || message.displayContent)
              "
              class="typing-indicator"
            >
              <span></span>
              <span></span>
              <span></span>
            </div>
            <div v-else>
              <div
                v-if="message.displayContent"
                v-text="message.displayContent"
              ></div>
              <div v-else v-html="formatMessage(message.content)"></div>
            </div>
          </div>
        </div>

        <!-- 用户头像 -->
        <div v-if="message.type === 'user'" class="avatar user-avatar">
          <div class="user-icon">👤</div>
        </div>
      </div>
    </div>

    <!-- 输入区域 -->
    <div class="chat-input-area">
      <div class="input-container">
        <input
          v-model="inputMessage"
          type="text"
          :placeholder="placeholderText"
          class="chat-input"
          @keyup.enter="sendMessage"
          :disabled="isLoading"
        />
        <button
          class="send-button"
          @click="sendMessage"
          :disabled="!inputMessage.trim() || isLoading"
        >
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
            <path
              d="M2.01 21L23 12L2.01 3L2 10L17 12L2 14L2.01 21Z"
              fill="white"
            />
          </svg>
        </button>
      </div>
      <div class="chat-actions">
        <button
          class="reset-button"
          @click="resetConversation"
          :disabled="isLoading"
        >
          重置对话
        </button>
      </div>
    </div>
  </div>
</template>

<script>
import { generateSessionId, formatSSEMessage } from "@/utils/sseUtils";
import settings from "@/settings";

export default {
  name: "ChatDialog",
  props: {
    placeholderText: {
      type: String,
      default: "和云浮局-分布式电源接入对台区重过载预警智能体聊天"
    }
  },
  data() {
    return {
      messages: [],
      inputMessage: "",
      isLoading: false,
      conversationId: generateSessionId(),
      parentMessageId: null,
      currentAbortController: null,
      pollInterval: null,
      typingDelayMs: 10
    };
  },
  methods: {
    async sendMessage() {
      if (!this.inputMessage.trim() || this.isLoading) return;

      const userMessage = {
        type: "user",
        content: this.inputMessage.trim(),
        timestamp: new Date()
      };
      this.messages.push(userMessage);

      const currentMessage = this.inputMessage.trim();
      this.inputMessage = "";

      await this.$nextTick();
      this.scrollToBottom();

      this.isLoading = true;
      try {
        await this.sendChatWithPolling(currentMessage);
      } catch (error) {
        console.error("发送消息失败:", error);
        this.messages.push({
          type: "bot",
          content: "抱歉,我暂时无法回复您的消息,请稍后再试。",
          timestamp: new Date()
        });
      } finally {
        this.isLoading = false;
        await this.$nextTick();
        this.scrollToBottom();
      }
    },

    async sendChatWithPolling(message) {
      console.log("使用长轮询方式发送聊天请求:", message);

      /**
       * 发送用户消息并与AI进行流式对话交互。
       * 该函数会创建一个机器人回复的消息对象,并将其推入消息列表中。
       * 随后通过fetch向后端发起聊天请求,支持SSE(Server-Sent Events)或普通JSON响应,
       * 并将返回的内容逐步追加到机器人的回复内容中,实现打字机效果。
       *
       * @param {string} message - 用户输入的原始消息内容
       */
      const botMessage = {
        type: "bot",
        content: "",
        displayContent: "",
        timestamp: new Date()
      };
      this.messages.push(botMessage);

      try {
        // 生成当前消息ID并清理用户输入内容
        const currentMessageId = generateSessionId();
        const cleanMessage = message.trim();
        // 检查消息是否为空
        if (!cleanMessage) {
          botMessage.content = "消息内容不能为空";
          return;
        }

        // 构造请求数据
        const requestData = {
          response_mode: "streaming",
          conversation_id: this.conversationId,
          files: [],
          query: cleanMessage,
          inputs: {},
          parent_message_id: this.parentMessageId || currentMessageId
          // conversation_id: "ee87ad3c-bde6-4d74-88bf-ba74d99c0974",
          // query: "什么是"重过载"?",
          // parent_message_id: "2cc5088a-24f6-4d5f-b2b4-9a6f9e1b3ad4"
        };

        // 更新父级消息ID
        this.parentMessageId = currentMessageId;

        // 创建用于取消请求的控制器
        this.currentAbortController = new AbortController();
        const signal = this.currentAbortController.signal;

        let cursor = null;
        let finished = false;

        // 开始长轮询处理流式响应
        while (!finished) {
          const payload = { ...requestData, cursor };

          try {
            // 向AI接口发送POST请求
            const res = await fetch(settings.aiPrefix + "/chat", {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify(payload),
              signal
            });

            // 请求失败则抛出错误
            if (!res.ok) throw new Error(`HTTP ${res.status}`);

            // 获取响应类型与文本内容
            const contentType = res.headers.get("content-type") || "";
            const rawText = await res.text();

            let events = [];

            // 判断是SSE格式还是普通JSON格式
            if (
              contentType.includes("text/event-stream") ||
              rawText.startsWith("data:")
            ) {
              // 处理SSE事件流:按行分割并解析每条消息
              const lines = rawText.split(/\r?\n/);
              console.log("lines", lines);

              for (const line of lines) {
                if (!line || !line.startsWith("data:")) continue;
                const jsonStr = line.slice(5).trim();
                if (!jsonStr) continue;
                try {
                  events.push(JSON.parse(jsonStr));
                } catch (_) {}
              }
            } else {
              // 尝试作为单个JSON对象解析
              try {
                events = [JSON.parse(rawText)];
              } catch (_) {
                events = [];
              }
            }

            // 遍历所有事件并更新bot消息内容
            for (let i = 0; i < events.length; i += 1) {
              const evt = events[i];
              const answerVal =
                (evt && evt.data && evt.data.answer) || (evt && evt.answer);

              // console.log("处理事件:", evt);
              // console.log("提取的answerVal:", answerVal);

              if (typeof answerVal === "string" && answerVal) {
                // console.log("开始打字效果处理answerVal:", answerVal);
                await this.appendWithTyping(botMessage, answerVal);
              }

              // 更新游标和结束标志位(基于当前事件)
              cursor = (evt && (evt.cursor || evt.next_cursor)) || cursor;
              if (
                (evt && (evt.done || evt.is_end || evt.finished)) ||
                (evt && evt.event === "workflow_finished") ||
                (evt && evt.event === "done") ||
                (evt && evt.event === "error")
              ) {
                finished = true;
              }
            }

            // 触发视图更新并滚动到底部
            await this.$nextTick();
            this.scrollToBottom();

            // 若未完成且无新事件及游标,则短暂等待避免频繁请求
            if (!finished && events.length === 0 && !cursor) {
              await new Promise((r) => setTimeout(r, 200));
            }
          } catch (err) {
            // 中断请求时退出循环
            if (err && err.name === "AbortError") break;
            console.error("长轮询失败:", err);
            botMessage.content += "\n请求失败,请稍后重试。";
            break;
          }
        }
      } catch (error) {
        // 兜底异常处理
        console.error("发送消息失败:", error);
        botMessage.content += "\n\n抱歉,我暂时无法回复您的消息,请稍后再试。";
      }
    },

    resetConversation() {
      this.messages = [];
      this.conversationId = generateSessionId();
      this.parentMessageId = null;

      this.messages.push({
        type: "bot",
        content:
          "您好! 😊 我是云浮局分布式电源接入对台区重过载预警智能体,有什么可以帮助您的吗?",
        timestamp: new Date()
      });
    },

    formatMessage(content) {
      return formatSSEMessage(content);
    },

    scrollToBottom() {
      const el = this.$refs.messagesContainer;
      if (el) el.scrollTop = el.scrollHeight;
    },

    async appendWithTyping(botMessage, text) {
      if (!text) return;
      // console.log("开始打字效果,文本:", text);
      // 累积到真实内容(已预定义属性,直接赋值保持响应)
      botMessage.content = (botMessage.content || "") + text;
      // 按字符逐个展现
      for (let i = 0; i < text.length; i += 1) {
        botMessage.displayContent = (botMessage.displayContent || "") + text[i];

        // console.log("当前显示内容:", botMessage.displayContent);

        // 等待一小段时间,形成打字效果
        // eslint-disable-next-line no-await-in-loop
        await new Promise((r) => setTimeout(r, this.typingDelayMs));
        // eslint-disable-next-line no-await-in-loop
        await this.$nextTick();
        // 保险触发一次刷新,避免个别环境下不重绘
        if (this.$forceUpdate) this.$forceUpdate();
        this.scrollToBottom();
      }
    }
  },
  mounted() {
    this.messages.push({
      type: "bot",
      content:
        "您好! 😊 我是云浮局分布式电源接入对台区重过载预警智能体,有什么可以帮助您的吗?",
      timestamp: new Date()
    });
  },
  beforeDestroy() {
    if (this.currentAbortController) {
      this.currentAbortController.abort();
      this.currentAbortController = null;
    }
    if (this.pollInterval) {
      clearInterval(this.pollInterval);
      this.pollInterval = null;
    }
  }
};
</script>

<style scoped>
.chat-dialog {
  display: flex;
  flex-direction: column;
  height: 600px;
  background-color: #f8f9fa;
  border-radius: 8px;
  overflow: hidden;
}

.chat-messages {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  background-color: #f8f9fa;
}

.message {
  display: flex;
  margin-bottom: 20px;
  align-items: flex-start;
}

.message.user {
  flex-direction: row-reverse;
}

.message-content {
  max-width: 70%;
  margin: 0 10px;
}

.message-bubble {
  padding: 12px 16px;
  border-radius: 18px;
  word-wrap: break-word;
  line-height: 1.4;
  white-space: pre-wrap; /* 保留换行,便于打字中显示 */
}

.message-bubble.user {
  background-color: #e3f2fd;
  color: #1976d2;
  border-bottom-right-radius: 4px;
}

.message-bubble.bot {
  background-color: white;
  color: #333;
  border-bottom-left-radius: 4px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.avatar {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}

.bot-avatar {
  background: linear-gradient(135deg, #4caf50, #2e7d32);
}

.user-avatar {
  background: linear-gradient(135deg, #2196f3, #1976d2);
}

.robot-icon,
.user-icon {
  font-size: 20px;
  color: white;
}

.chat-input-area {
  padding: 20px;
  background-color: white;
  border-top: 1px solid #e9ecef;
}

.chat-actions {
  margin-top: 10px;
  display: flex;
  justify-content: flex-end;
}

.reset-button {
  padding: 8px 16px;
  border: 1px solid #dc3545;
  border-radius: 6px;
  background-color: white;
  color: #dc3545;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.2s;
}

.reset-button:hover:not(:disabled) {
  background-color: #dc3545;
  color: white;
}

.reset-button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.test-button {
  padding: 8px 16px;
  border: 1px solid #28a745;
  border-radius: 6px;
  background-color: white;
  color: #28a745;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.2s;
  margin-left: 10px;
}

.test-button:hover:not(:disabled) {
  background-color: #28a745;
  color: white;
}

.test-button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.compare-button {
  padding: 8px 16px;
  border: 1px solid #ffc107;
  border-radius: 6px;
  background-color: white;
  color: #ffc107;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.2s;
  margin-left: 10px;
}

.compare-button:hover:not(:disabled) {
  background-color: #ffc107;
  color: white;
}

.compare-button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.input-container {
  display: flex;
  align-items: center;
  background-color: white;
  border-radius: 25px;
  padding: 8px 16px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.chat-input {
  flex: 1;
  border: none;
  outline: none;
  padding: 12px 0;
  font-size: 14px;
  background: transparent;
}

.chat-input::placeholder {
  color: #999;
}

.send-button {
  width: 36px;
  height: 36px;
  border: none;
  border-radius: 50%;
  background: linear-gradient(135deg, #2196f3, #1976d2);
  color: white;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: all 0.2s;
  box-shadow: 0 2px 4px rgba(33, 150, 243, 0.3);
}

.send-button:hover:not(:disabled) {
  background: linear-gradient(135deg, #1976d2, #1565c0);
  transform: translateY(-1px);
  box-shadow: 0 4px 8px rgba(33, 150, 243, 0.4);
}

.send-button:disabled {
  background: #ccc;
  cursor: not-allowed;
  transform: none;
  box-shadow: none;
}

/* 加载动画 */
.loading .typing-indicator {
  display: flex;
  align-items: center;
  gap: 4px;
}

.typing-indicator span {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background-color: #999;
  animation: typing 1.4s infinite ease-in-out;
}

.typing-indicator span:nth-child(1) {
  animation-delay: -0.32s;
}

.typing-indicator span:nth-child(2) {
  animation-delay: -0.16s;
}

@keyframes typing {
  0%,
  80%,
  100% {
    transform: scale(0.8);
    opacity: 0.5;
  }
  40% {
    transform: scale(1);
    opacity: 1;
  }
}

/* 滚动条样式 */
.chat-messages::-webkit-scrollbar {
  width: 6px;
}

.chat-messages::-webkit-scrollbar-track {
  background: #f1f1f1;
}

.chat-messages::-webkit-scrollbar-thumb {
  background: #c1c1c1;
  border-radius: 3px;
}

.chat-messages::-webkit-scrollbar-thumb:hover {
  background: #a8a8a8;
}

/* 消息内容样式优化 */
.message-bubble strong {
  font-weight: 600;
  color: #1976d2;
}

.message-bubble .emoji {
  font-size: 1.2em;
}

/* 响应式设计 */
@media (max-width: 768px) {
  .chat-dialog {
    height: 500px;
  }

  .message-content {
    max-width: 85%;
  }

  .chat-input-area {
    padding: 15px;
  }
}
</style>
相关推荐
崔庆才丨静觅1 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60612 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了2 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅2 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅2 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅3 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment3 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅3 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊3 小时前
jwt介绍
前端
爱敲代码的小鱼3 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax