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>
相关推荐
Dream-Y.ocean19 分钟前
鸿蒙PC平台宅匠 3D 室内设计适配实战:基于 Electron 壳方案的 Three.js 双渲染引擎与参数化户型编辑器开发
javascript·3d·harmonyos
Csvn1 小时前
性能监控
前端
Highcharts.js1 小时前
Highcharts Gantt使用符号的自定义数据标签开发甘特图示例
javascript·甘特图·highcharts·图表开发·实战代码·响应式图表
Csvn2 小时前
构建工具
前端
FRing032 小时前
vue2干货总结3(computed计算属性、watch监听器)
开发语言·前端·vue.js·vue2
Java小卷3 小时前
JuanForm vs FormCreate!Vue3 表单引擎联动配置 PK!
vue.js·低代码
kyriewen4 小时前
Skills 榜前 10 我全装了:只有 3 个能直接用
前端·javascript·程序员
IT_陈寒5 小时前
当Java的hashCode遇上Lombok,我的缓存被全清了
前端·人工智能·后端
工控丫丫5 小时前
帆软看板中经常使用的公式函数及经常搭的组合
前端·javascript·数据库
jerrywus5 小时前
我用 GPT-6.1-sol 做了台会讲解的 3D 售货机:60 罐饮料,一口没喝上
前端·agent·claude