芋道AI扩展之对话消息中断后继续生成

芋道源码的AI对话中断后目前不支持继续生成
在这里插入图片描述
在这里插入图片描述
基于此需求开始更改

数据库表变更

ai_chat_message表增加字段interrupted

ALTER TABLE `ai_chat_message`
ADD COLUMN `interrupted` BIT(1) NOT NULL DEFAULT b'0' COMMENT 'AI响应是否中断';

在这里插入图片描述

实体变更

AiChatMessageDOAiChatMessageRespVOAiChatMessageSendRespVO.Message增加interrupted属性

    /**
     * AI 响应是否中断(客户端断开、取消等)
     */
    private Boolean interrupted;

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

AiChatMessageSendReqVO增加 messageId属性

    /**
     * 消息编号(用于“继续生成”)
     */
    @Schema(description = "AI消息编号(用于继续生成)")
    private Long messageId;

在这里插入图片描述

业务代码变更

后端

AiChatMessageServiceImpl 将原流式对话发送信息sendChatMessageStream增加中断标记操作

@Override
    public Flux<CommonResult<AiChatMessageSendRespVO>> sendChatMessageStream(AiChatMessageSendReqVO sendReqVO,
            Long userId) {
        // 1.1 校验对话存在
        AiChatConversationDO conversation = chatConversationService
                .validateChatConversationExists(sendReqVO.getConversationId());
        if (ObjUtil.notEqual(conversation.getUserId(), userId)) {
            throw exception(CHAT_CONVERSATION_NOT_EXISTS);
        }
        List<AiChatMessageDO> historyMessages = chatMessageMapper.selectListByConversationId(conversation.getId());
        // 1.2 校验模型
        AiModelDO model = modalService.validateModel(conversation.getModelId());
        StreamingChatModel chatModel = modalService.getChatModel(model.getId());

        // 2. 知识库找回
        List<AiKnowledgeSegmentSearchRespBO> knowledgeSegments = recallKnowledgeSegment(sendReqVO.getContent(),
                conversation);

        // 3. 插入 user 发送消息
        AiChatMessageDO userMessage = createChatMessage(conversation.getId(), null, model,
                userId, conversation.getRoleId(), MessageType.USER, sendReqVO.getContent(), sendReqVO.getUseContext(),null,sendReqVO.getFilePath(),sendReqVO.getFileName());


        // 4.1 插入 assistant 接收消息
        AiChatMessageDO assistantMessage = createChatMessage(conversation.getId(), userMessage.getId(), model,
                userId, conversation.getRoleId(), MessageType.ASSISTANT, "", sendReqVO.getUseContext(),
                knowledgeSegments,sendReqVO.getFilePath(),sendReqVO.getFileName());
        //记录原始对话内容
        String oldMessage = sendReqVO.getContent();
        sendReqVO.setContent(userMessage.getContent());

        // 4.2 构建 Prompt,并进行调用
        Prompt prompt = buildPrompt(conversation, historyMessages, knowledgeSegments, model, sendReqVO);
        Flux<ChatResponse> streamResponse = chatModel.stream(prompt);


        // 4.3 流式返回
        userMessage.setContent(oldMessage);

        AtomicReference<StringBuffer> contentRef = new AtomicReference<>(new StringBuffer());

        return streamResponse.map(chunk -> {
            // 处理知识库的返回,只有首次才有
                    List<AiChatMessageRespVO.KnowledgeSegment> segments = null;
                    if (contentRef.get().length() == 0) {
                        Map<Long, AiKnowledgeDocumentDO> documentMap = TenantUtils.executeIgnore(() ->
                                knowledgeDocumentService.getKnowledgeDocumentMap(
                                        convertSet(knowledgeSegments, AiKnowledgeSegmentSearchRespBO::getDocumentId)));
                        segments = BeanUtils.toBean(knowledgeSegments, AiChatMessageRespVO.KnowledgeSegment.class, segment -> {
                            AiKnowledgeDocumentDO document = documentMap.get(segment.getDocumentId());
                            segment.setDocumentName(document != null ? document.getName() : null);
                        });
                    }
                    // 处理返回内容
                    String newContent = chunk.getResult() != null ? chunk.getResult().getOutput().getText() : null;
                    newContent = StrUtil.nullToDefault(newContent, "");

                    contentRef.get().append(newContent);

                    // 实时保存到数据库(可加频率控制,如每 200ms 一次)
                    String currentContent = contentRef.get().toString();
                    TenantUtils.executeIgnore(() ->
                            chatMessageMapper.updateById(
                                    new AiChatMessageDO().setId(assistantMessage.getId()).setContent(currentContent)
                            )
                    );

                    return success(new AiChatMessageSendRespVO()
                            .setSend(BeanUtils.toBean(userMessage, AiChatMessageSendRespVO.Message.class))
                            .setReceive(BeanUtils.toBean(assistantMessage, AiChatMessageSendRespVO.Message.class)
                                    .setContent(newContent)
                                    .setSegments(segments)));
                })
                .doOnCancel(() -> {
                    String finalContent = contentRef.get().toString();
                    TenantUtils.executeIgnore(() ->
                            chatMessageMapper.updateById(
                                    new AiChatMessageDO()
                                            .setId(assistantMessage.getId())
                                            .setContent(finalContent)
                                            .setInterrupted(true)  //标记为中断
                            )
                    );
                })
                // 忽略租户,因为 Flux 异步无法透传租户
                .doOnComplete(() -> {
                    String finalContent = contentRef.get().toString();
                    TenantUtils.executeIgnore(() ->
                            chatMessageMapper.updateById(
                                    new AiChatMessageDO().setId(assistantMessage.getId()).setContent(finalContent)
                            )
                    );
                })
                .doOnError(throwable -> {
                    log.error("[sendChatMessageStream][userId({}) sendReqVO({}) 发生异常]", userId, sendReqVO, throwable);
                    // 忽略租户,因为 Flux 异步无法透传租户
                    String partialContent = contentRef.get().toString();
                    String fullContent = partialContent + "\n\n[AI响应中断:服务异常]";
                    TenantUtils.executeIgnore(() ->
                            chatMessageMapper.updateById(
                                    new AiChatMessageDO()
                                            .setId(assistantMessage.getId())
                                            .setContent(fullContent)
                                            .setInterrupted(true)
                            )
                    );
                })
                .onErrorResume(error -> Flux.just(error(ErrorCodeConstants.CHAT_STREAM_ERROR)));
    }

AiChatMessageServiceImpl增加继续生成resumeChatMessageStream 方法


    /**
     * 继续生成 AI 回复(流式)
     *
     * @param sendReqVO 请求参数(包含 messageId)
     * @param userId    用户编号
     * @return 流式响应
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Flux<CommonResult<AiChatMessageSendRespVO>> resumeChatMessageStream(
            AiChatMessageSendReqVO sendReqVO, Long userId) {

        Long messageId = sendReqVO.getMessageId();
        if (messageId == null) {
            throw exception(CHAT_MESSAGE_NOT_EXIST);
        }
        // 1. 查询目标AI消息(必须是assistant类型且被中断)
        AiChatMessageDO assistantMessage = chatMessageMapper.selectById(messageId);
        if (assistantMessage == null || !userId.equals(assistantMessage.getUserId())) {
            throw exception(CHAT_MESSAGE_NOT_EXIST);
        }
        if (!MessageType.ASSISTANT.getValue().equals(assistantMessage.getType())) {
            return Flux.just(success(new AiChatMessageSendRespVO()
                    .setReceive(new AiChatMessageSendRespVO.Message()
                            .setId(assistantMessage.getId())
                            .setContent("")
                            .setInterrupted(false))));
        }
        if (!Boolean.TRUE.equals(assistantMessage.getInterrupted())) {
            return Flux.just(success(new AiChatMessageSendRespVO()
                    .setReceive(new AiChatMessageSendRespVO.Message()
                            .setId(assistantMessage.getId())
                            .setContent("")
                            .setInterrupted(false))));
        }
        // 2. 获取对话
        AiChatConversationDO conversation = chatConversationService.validateChatConversationExists(assistantMessage.getConversationId());
        if (!userId.equals(conversation.getUserId())) {
            throw exception(CHAT_CONVERSATION_NOT_EXISTS);
        }
        // 3. 获取该对话的所有历史消息(按时间升序)
        List<AiChatMessageDO> messages = chatMessageMapper.selectListByConversationId(conversation.getId());
        if (CollUtil.isEmpty(messages)) {
            throw exception(CHAT_MESSAGE_NOT_EXIST);
        }
        // 4. 构建用于AI调用的消息列表 (List<Message>)
        List<Message> chatMessages = new ArrayList<>();
        boolean foundTarget = false;
        for (AiChatMessageDO message : messages) {
            if (message.getId().equals(messageId)) {
                // 找到目标消息,将其内容加入上下文
                chatMessages.add(AiUtils.buildMessage(MessageType.ASSISTANT.getValue(), message.getContent()));
                foundTarget = true;
                break; // 关键:中断后,后续消息不加入
            }
            MessageType messageType = MessageType.fromValue(message.getType());
            chatMessages.add(AiUtils.buildMessage(messageType.getValue(), message.getContent()));
        }
        if (!foundTarget) {
            throw exception(CHAT_MESSAGE_NOT_EXIST);
        }
        // 5. (可选)加入知识库检索逻辑(如果你在 resume 时也需要)
        // List<AiKnowledgeSegmentSearchRespBO> knowledgeSegments = knowledgeService.searchRelevantSegments(sendReqVO.getContent());
        // if (CollUtil.isNotEmpty(knowledgeSegments)) {
        //     String knowledgeContent = buildKnowledgeContent(knowledgeSegments);
        //     chatMessages.add(AiUtils.buildMessage(MessageType.SYSTEM, knowledgeContent));
        // }

        // 6. 添加“继续”指令
        chatMessages.add(AiUtils.buildMessage(MessageType.USER.getValue(), "请继续完成上面未完成的回答,不要重复之前的内容,不要说“好的,我将继续”,直接接着回复内容"));
        // 7. 获取模型和 ChatModel
        AiModelDO model = modalService.validateModel(conversation.getModelId());
        ChatModel chatModel = modalService.getChatModel(model.getId());
        // 8. 构建 ChatOptions(完全复用你原有逻辑)
        Set<String> toolNames = null;
        Map<String, Object> toolContext = Map.of();
        if (conversation.getRoleId() != null) {
            AiChatRoleDO chatRole = chatRoleService.getChatRole(conversation.getRoleId());
            if (chatRole != null && CollUtil.isNotEmpty(chatRole.getToolIds())) {
                toolNames = convertSet(toolService.getToolList(chatRole.getToolIds()), AiToolDO::getName);
                toolContext = AiUtils.buildCommonToolContext();
            }
        }
        AiPlatformEnum platform = AiPlatformEnum.validatePlatform(model.getPlatform());
        ChatOptions chatOptions = AiUtils.buildChatOptions(
                platform, model.getModel(),
                conversation.getTemperature(), conversation.getMaxTokens(),
                toolNames, toolContext);

        // 9. 创建 Prompt
        Prompt prompt = new Prompt(chatMessages, chatOptions);
        // 10. 开始流式调用
        Flux<ChatResponse> streamResponse = chatModel.stream(prompt);
        // 11. 初始化内容缓冲区(包含已生成的部分)
        AtomicReference<StringBuffer> contentRef = new AtomicReference<>(new StringBuffer(assistantMessage.getContent()));
        // 12. 处理流式响应(完全照搬你原始的 .map() 逻辑)
        return streamResponse.map(chunk -> {
                    // 处理知识库的返回(只有首次才有)
                    List<AiChatMessageRespVO.KnowledgeSegment> segments = null;
                    if (contentRef.get().length() == assistantMessage.getContent().length()) { // 判断是否是 resume 后的“首次”
                        // 如果你需要在 resume 时也返回知识库片段,可在此处添加逻辑
                        // 例如:segments = buildKnowledgeSegments(knowledgeSegments);
                    }
                    String newContent = chunk.getResult() != null ? chunk.getResult().getOutput().getText() : null;
                    newContent = StrUtil.nullToDefault(newContent, "");
                    contentRef.get().append(newContent);
                    // 实时保存到数据库
                    String currentContent = contentRef.get().toString();
                    TenantUtils.executeIgnore(() ->
                            chatMessageMapper.updateById(
                                    new AiChatMessageDO()
                                            .setId(assistantMessage.getId())
                                            .setContent(currentContent)
                                            .setInterrupted(false) // 正在生成
                            )
                    );
                    // 构造返回 VO
                    AiChatMessageSendRespVO.Message receive = BeanUtils.toBean(assistantMessage, AiChatMessageSendRespVO.Message.class)
                            .setContent(newContent)
                            .setSegments(segments);

                    return success(new AiChatMessageSendRespVO()
                            .setReceive(receive));
                })
                .doOnCancel(() -> {
                    String finalContent = contentRef.get().toString();
                    TenantUtils.executeIgnore(() ->
                            chatMessageMapper.updateById(
                                    new AiChatMessageDO()
                                            .setId(assistantMessage.getId())
                                            .setContent(finalContent)
                                            .setInterrupted(true)
                            )
                    );
                })
                // 忽略租户,因为 Flux 异步无法透传租户
                .doOnComplete(() -> {
                    String finalContent = contentRef.get().toString();
                    TenantUtils.executeIgnore(() ->
                            chatMessageMapper.updateById(
                                    new AiChatMessageDO().setId(assistantMessage.getId()).setContent(finalContent).setInterrupted(false)
                            )
                    );
                })
                .doOnError(throwable -> {
                    log.error("[sendChatMessageStream][userId({}) sendReqVO({}) 发生异常]", userId, sendReqVO, throwable);
                    // 忽略租户,因为 Flux 异步无法透传租户
                    String partialContent = contentRef.get().toString();
                    String fullContent = partialContent + "\n\n[AI响应中断:服务异常]";
                    TenantUtils.executeIgnore(() ->
                            chatMessageMapper.updateById(
                                    new AiChatMessageDO()
                                            .setId(assistantMessage.getId())
                                            .setContent(fullContent)
                                            .setInterrupted(true)
                            )
                    );
                })
                .onErrorResume(error -> Flux.just(error(ErrorCodeConstants.CHAT_STREAM_ERROR)));
    }

AiChatMessageService 增加 resumeChatMessageStream

    /**
     * 继续生成AI回复(流式)
     * @param sendReqVO
     * @param messageId
     * @return
     */
    Flux<CommonResult<AiChatMessageSendRespVO>> resumeChatMessageStream(AiChatMessageSendReqVO sendReqVO, Long messageId);

AiChatMessageController增加resumeChatMessageStream

    @Operation(summary = "继续生成AI回复(流式)", description = "流式返回,接着上次未完成的内容继续生成")
    @PostMapping(value = "/resume-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<CommonResult<AiChatMessageSendRespVO>> resumeChatMessageStream(
            @Valid @RequestBody AiChatMessageSendReqVO sendReqVO) {
        Long userId = getLoginUserId();
        return chatMessageService.resumeChatMessageStream(sendReqVO, userId);
    }

前端

在这里插入图片描述
/src/views/Home/ai/chat/index/index.vue变更
在这里插入图片描述

@on-resume="handleResume"

在这里插入图片描述

  // 获取消息列表
  activeMessageList.value = await ChatMessageApi.getChatMessageListByConversationId(
    activeConversationId.value
  )

在这里插入图片描述

/**
 * 处理“继续生成”被中断的消息
 */
 const handleResume = async (message: ChatMessageVO) => {
  conversationInAbortController.value = new AbortController()
  conversationInProgress.value = true;
  try {
    await ChatMessageApi.resumeChatMessageStream(
      message.conversationId,  
      message.id,              
      message.content, 
      conversationInAbortController.value,         
      (event) => {          
        if (event.data === '[DONE]') {
          message.interrupted = false
          return
        }
        try {
          const result = JSON.parse(event.data)
          if (result.code === 0 && result.data?.receive?.content) {
            const newContent = result.data.receive.content
            message.content += newContent // 追加新内容
          }
        } catch (e) {
          console.error('解析流数据失败:', e)
        }
      },
      (error) => {
        // message.alert(`对话异常! ${error}`)
        ElMessageBox({
          title: '提示',
          message: h('p', null, [
            h('p', { style: 'color: #303133' }, `对话异常! ${error}`),
          ]),
          confirmButtonText: '确定',
          cancelButtonText: '取消',
          showCancelButton: false,
          dangerouslyUseHTMLString: true,
          customClass: 'commonElMessageBox',
          icon: markRaw(InfoFilled),
          center: true,
        })
        stopStream()
        throw new Error(error);
      },
      () => {
        stopStream()
      }
    )
  } catch (err) {
    console.error('调用 resume API 失败:', err)
    ElMessage.error('网络错误')
  }
}

完整代码:

<template>
  <el-container class="ai-layout" v-loading="isLoading">
    <!-- 左侧:对话详情 -->
    <el-container class="detail-container">
      <el-header class="header">
        <!-- <div class="title">
          {{ activeConversation?.title ? activeConversation?.title : '对话' }}
          <span v-if="activeMessageList.length">({{ activeMessageList.length }})</span>
        </div>
        <div class="btns" v-if="activeConversation">
          <el-button type="primary" bg plain size="small" @click="openChatConversationUpdateForm">
            <span v-html="activeConversation?.modelName"></span>
            <Icon icon="ep:setting" class="ml-10px" />
          </el-button>
          <el-button size="small" class="btn" @click="handlerMessageClear">
            <Icon icon="heroicons-outline:archive-box-x-mark" color="#787878" />
          </el-button>
          <el-button size="small" class="btn">
            <Icon icon="ep:download" color="#787878" />
          </el-button>
          <el-button size="small" class="btn" @click="handleGoTopMessage">
            <Icon icon="ep:top" color="#787878" />
          </el-button>
        </div> -->
        <el-button v-debounce class="btn-new-conversation font-color-FFFFFF" type="primary" size="default" @click="handleConversationCreate">
          <Icon icon="ep:plus" class="mr-5px" />
          新建对话
        </el-button>
      </el-header>

      <!-- main:消息列表 -->
      <el-main class="main-container bg-FFFFFF">
        <div>
          <div class="message-container">
            <!-- 情况一:消息加载中 -->
            <MessageLoading v-if="activeMessageListLoading" />
            <!-- 情况二:无聊天对话时 -->
            <MessageNewConversation
              v-if="!activeConversation"
              @on-new-conversation="handleConversationCreate"
            />
            <!-- 情况三:消息列表为空 -->
            <MessageListEmpty
              v-if="!activeMessageListLoading && messageList.length === 0 && activeConversation"
              @on-prompt="doSendMessage"
            />
            <!-- 情况四:消息列表不为空 -->
            <MessageList
              v-if="!activeMessageListLoading && messageList.length > 0"
              ref="messageRef"
              :conversation="activeConversation"
              :list="messageList"
              @on-delete-success="handleMessageDelete"
              @on-edit="handleMessageEdit"
              @on-refresh="handleMessageRefresh"
              @on-resume="handleResume"
            />
          </div>
        </div>
      </el-main>

      <!-- 底部 -->
      <el-footer class="footer-container bg-FFFFFF">
        <!-- <div class="quick-instructions display-flex-top">
          <div class="left-box display-flex-left" @click="openQuick()">
            <span class="font-size-16 font-color-175DEC margin-r-8">快捷指令</span>
            <el-icon :size="14" :color="'#175DEC'" v-if="showQuickList">
              <ArrowUp />
            </el-icon>
            <el-icon :size="14" :color="'#175DEC'" v-else>
              <ArrowDown />
            </el-icon>
          </div>
          <div class="flex-1 quick-box" v-if="!showQuickList"></div>
          <el-scrollbar height="100%" class="flex-1 quick-box" v-if="showQuickList">
            <div
              class="quick-list display-flex"
              v-for="(item,index) in quickOptions"
              :key="index"
            >
              <el-popover
                placement="top-start"
                trigger="hover"
                popper-class="quick-popover"
                :hide-after="10"
                :ref="item.type"
              >
                <template #reference>
                  <div>
                    <span class="">{{getDictLabel(DICT_TYPE.AI_INSTRUCTION_TYPE, item.type)}}</span>
                    <el-icon :size="14" :color="'#175DEC'">
                      <ArrowDown />
                    </el-icon>
                  </div>
                </template>
                <div
                  class="quick-children-list"
                  v-for="(citem,cindex) in item.instructionList"
                  :key="cindex"
                  @click="chooseQuickChildren(citem)"
                >
                  <span class="">{{citem.content}}</span>
                </div>
              </el-popover>
            </div>
          </el-scrollbar>
        </div> -->
        <form class="prompt-from">
          <el-input
            class="prompt-input"
            v-model.trim="prompt"
            maxlength="5000"
            type="textarea"
            placeholder="通过Shift+回车换行"
            @keyup.enter="handleSendByKeydown($event)"
          />
          <!-- <textarea
            class="prompt-input"
            v-model.trim="prompt"
            maxlength="5000"
            @keyup.enter="handleSendByKeydown"
            @input="handlePromptInput"
            @compositionstart="onCompositionstart"
            @compositionend="onCompositionend"
            placeholder="通过Shift+回车换行"
          ></textarea> -->
          <div class="prompt-btns display-flex">
            <!-- <div
              class="deep-btn display-flex"
              v-for="(item,index) in btnList"
              :key="index"
              :class="{'active': item.isChecked}"
              @click="chooseBtn(item)"
            >
              <img class="deep-img" :src="item.isChecked ? item.activeImg : item.defImg" />
              <span class="deep-text">{{item.title}}</span>
            </div> -->
            <!-- <div class="flex-1"></div> -->
            <div class="flex-1 display-flex overflow-hidden">
              <div class="flex-1"></div>
              <span class="file-name-text ellipsis" v-if="fileUrls && fileUrls.length > 0">{{fileUrls[fileUrls.length -1].name + '.' +(fileUrls[fileUrls.length -1].url.substring(fileUrls[fileUrls.length -1].url.lastIndexOf('.') + 1))}}</span>
              <el-icon class="del-file" :size="14" :color="'#606266'" v-if="fileUrls && fileUrls.length > 0" @click="delFile()">
                <Delete />
              </el-icon>
            </div>
            <UploadFile v-model="fileUrls" :isShowTip="false" :limit="1" class="UploadFile" />
            <img class="upload-img" v-if="false" src="@/assets/imgs/home/05.png" />
            <!-- <div>
              <el-switch v-model="enableContext" />
              <span class="ml-5px text-14px text-#8f8f8f">上下文</span>
            </div> -->
            <!-- <el-button
              type="primary"
              size="default"
              class="send-btn"
              color="#175DEC"
              @click="handleSendByButton"
              :loading="conversationInProgress"
              v-if="conversationInProgress == false"
            >
              <img class="message-img" src="@/assets/imgs/home/06.png" />
            </el-button> -->
              <!-- From Uiverse.io by MikeeMikee --> 
            <button class="button" type="button" @click="handleSendByButton()" v-if="conversationInProgress == false">
              <div class="button__circle">
                <svg
                  viewBox="0 0 100 100"
                  fill="none"
                  xmlns="http://www.w3.org/2000/svg"
                  class="button__icon"
                  width="25"
                >
                  <path
                    d="M95,9.9c-1.3-1.1-3.4-1.2-7-0.1c0,0,0,0,0,0c-2.5,0.8-24.7,9.2-44.3,17.3c-17.6,7.3-31.9,13.7-33.6,14.5  c-1.9,0.6-6,2.4-6.2,5.2c-0.1,1.8,1.4,3.4,4.3,4.7c3.1,1.6,16.8,6.2,19.7,7.1c1,3.4,6.9,23.3,7.2,24.5c0.4,1.8,1.6,2.8,2.2,3.2  c0.1,0.1,0.3,0.3,0.5,0.4c0.3,0.2,0.7,0.3,1.2,0.3c0.7,0,1.5-0.3,2.2-0.8c3.7-3,10.1-9.7,11.9-11.6c7.9,6.2,16.5,13.1,17.3,13.9  c0,0,0.1,0.1,0.1,0.1c1.9,1.6,3.9,2.5,5.7,2.5c0.6,0,1.2-0.1,1.8-0.3c2.1-0.7,3.6-2.7,4.1-5.4c0-0.1,0.1-0.5,0.3-1.2  c3.4-14.8,6.1-27.8,8.3-38.7c2.1-10.7,3.8-21.2,4.8-26.8c0.2-1.4,0.4-2.5,0.5-3.2C96.3,13.5,96.5,11.2,95,9.9z M30,58.3l47.7-31.6  c0.1-0.1,0.3-0.2,0.4-0.3c0,0,0,0,0,0c0.1,0,0.1-0.1,0.2-0.1c0.1,0,0.1,0,0.2-0.1c-0.1,0.1-0.2,0.4-0.4,0.6L66,38.1  c-8.4,7.7-19.4,17.8-26.7,24.4c0,0,0,0,0,0.1c0,0-0.1,0.1-0.1,0.1c0,0,0,0.1-0.1,0.1c0,0.1,0,0.1-0.1,0.2c0,0,0,0.1,0,0.1  c0,0,0,0,0,0.1c-0.5,5.6-1.4,15.2-1.8,19.5c0,0,0,0,0-0.1C36.8,81.4,31.2,62.3,30,58.3z"
                    fill="currentColor"
                  />
                </svg>

                <svg
                  viewBox="0 0 100 100"
                  fill="none"
                  width="25"
                  xmlns="http://www.w3.org/2000/svg"
                  class="button__icon button__icon--copy"
                >
                  <path
                    d="M95,9.9c-1.3-1.1-3.4-1.2-7-0.1c0,0,0,0,0,0c-2.5,0.8-24.7,9.2-44.3,17.3c-17.6,7.3-31.9,13.7-33.6,14.5  c-1.9,0.6-6,2.4-6.2,5.2c-0.1,1.8,1.4,3.4,4.3,4.7c3.1,1.6,16.8,6.2,19.7,7.1c1,3.4,6.9,23.3,7.2,24.5c0.4,1.8,1.6,2.8,2.2,3.2  c0.1,0.1,0.3,0.3,0.5,0.4c0.3,0.2,0.7,0.3,1.2,0.3c0.7,0,1.5-0.3,2.2-0.8c3.7-3,10.1-9.7,11.9-11.6c7.9,6.2,16.5,13.1,17.3,13.9  c0,0,0.1,0.1,0.1,0.1c1.9,1.6,3.9,2.5,5.7,2.5c0.6,0,1.2-0.1,1.8-0.3c2.1-0.7,3.6-2.7,4.1-5.4c0-0.1,0.1-0.5,0.3-1.2  c3.4-14.8,6.1-27.8,8.3-38.7c2.1-10.7,3.8-21.2,4.8-26.8c0.2-1.4,0.4-2.5,0.5-3.2C96.3,13.5,96.5,11.2,95,9.9z M30,58.3l47.7-31.6  c0.1-0.1,0.3-0.2,0.4-0.3c0,0,0,0,0,0c0.1,0,0.1-0.1,0.2-0.1c0.1,0,0.1,0,0.2-0.1c-0.1,0.1-0.2,0.4-0.4,0.6L66,38.1  c-8.4,7.7-19.4,17.8-26.7,24.4c0,0,0,0,0,0.1c0,0-0.1,0.1-0.1,0.1c0,0,0,0.1-0.1,0.1c0,0.1,0,0.1-0.1,0.2c0,0,0,0.1,0,0.1  c0,0,0,0,0,0.1c-0.5,5.6-1.4,15.2-1.8,19.5c0,0,0,0,0-0.1C36.8,81.4,31.2,62.3,30,58.3z"
                    fill="currentColor"
                  />
                </svg>
              </div>
            </button>
            <el-button
              type="primary"
              size="default"
              class="send-cancle-btn"
              color="#175DEC"
              @click="stopStream()"
              v-if="conversationInProgress == true"
            >
              <img class="message-img margin-r-6" src="@/assets/imgs/home/09.png" />
              <span style="font-size: 12px">取消生成</span>
            </el-button>
          </div>
        </form>
      </el-footer>
    </el-container>
    <!-- 右侧:对话列表 -->
    <ConversationList
      :active-id="activeConversationId"
      ref="conversationListRef"
      @on-conversation-create="handleConversationCreateSuccess"
      @on-conversation-click="handleConversationClick"
      @on-conversation-clear="handleConversationClear"
      @on-conversation-delete="handlerConversationDelete"
    />

    <!-- 更新对话 Form -->
    <ConversationUpdateForm
      ref="conversationUpdateFormRef"
      @success="handleConversationUpdateSuccess"
    />
  </el-container>
</template>

<script setup lang="ts">
import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
import ConversationList from './components/conversation/ConversationList.vue'
import ConversationUpdateForm from './components/conversation/ConversationUpdateForm.vue'
import MessageList from './components/message/MessageList.vue'
import MessageListEmpty from './components/message/MessageListEmpty.vue'
import MessageLoading from './components/message/MessageLoading.vue'
import MessageNewConversation from './components/message/MessageNewConversation.vue'
import { ArrowDown, ArrowUp, InfoFilled, Delete } from '@element-plus/icons-vue'
import { InstructionApi, InstructionVO } from '@/api/ai/instruction'
import { getDictLabel, DICT_TYPE } from '@/utils/dict'

/** AI 聊天对话 列表 */
// defineOptions({ name: 'AiChat' })
const route = useRoute() // 路由
const message = useMessage() // 消息弹窗

// 聊天对话
const conversationListRef = ref()
const activeConversationId = ref<any | null>(null) // 选中的对话编号
const activeConversation = ref<ChatConversationVO | any>(null) // 选中的 Conversation
const conversationInProgress = ref(false) // 对话是否正在进行中。目前只有【发送】消息时,会更新为 true,避免切换对话、删除对话等操作

// 消息列表
const messageRef = ref()
const activeMessageList = ref<ChatMessageVO[]>([]) // 选中对话的消息列表
const activeMessageListLoading = ref<boolean>(false) // activeMessageList 是否正在加载中
const activeMessageListLoadingTimer = ref<any>() // activeMessageListLoading Timer 定时器。如果加载速度很快,就不进入加载中
// 消息滚动
const textSpeed = ref<number>(50) // Typing speed in milliseconds
const textRoleRunning = ref<boolean>(false) // Typing speed in milliseconds

// 发送消息输入框
const isComposing = ref(false) // 判断用户是否在输入
const conversationInAbortController = ref<any>() // 对话进行中 abort 控制器(控制 stream 对话)
const inputTimeout = ref<any>() // 处理输入中回车的定时器
const prompt = ref<string>() // prompt
const enableContext = ref<boolean>(true) // 是否开启上下文
// 接收 Stream 消息
const receiveMessageFullText = ref('')
const receiveMessageDisplayedText = ref('')
const showQuickList = ref(false);
const quickOptions = reactive <InstructionVO[]>([]);

/** 查询列表 */
const getQuickOptions = async () => {
  const res = await InstructionApi.getInstructionGroupPage({
    pageNo: 1,
    pageSize: 100,
  })

  quickOptions.length = 0;
  res.list.forEach((el: InstructionVO) => {
    quickOptions.push(el);
  })
}
const { proxy } = getCurrentInstance() as any
const fileUrls = ref([] as any);

/** 选择指令 */
const chooseQuickChildren = (e: any) => {
  prompt.value += e.content;
  const popNode = proxy.$refs[e.type][0];
  popNode.hide();
}

import btnImg1 from '@/assets/imgs/home/04.png'
import btnImg2 from '@/assets/imgs/home/11.png'
import btnImg3 from '@/assets/imgs/home/10.png'
import btnImg4 from '@/assets/imgs/home/03.png'
const btnList =  reactive([{
  id: '17',
  title: '深度思考(R1)',
  defImg: btnImg1,
  activeImg: btnImg2,
  model: "deepseek-chat",
  name: "deepseek-chat",
  isChecked: true,
},{
  id: '21',
  title: '联网搜索',
  defImg: btnImg3,
  activeImg: btnImg4,
  model: "deepseek-reasoner",
  name: "deepseek-reasoner",
  isChecked: false,
}]);
const activeBtnId = ref('17');
const isLoading = ref(false);

// =========== 【聊天对话】相关 ===========

const openQuick = () => {
  showQuickList.value = !showQuickList.value;
}

/** btn选择 */
const chooseBtn = async (item) => {
  // 对话进行中,不允许切换
  if (conversationInProgress.value) {
    // message.alert('对话中,不允许切换!')
    await ElMessageBox({
      title: '提示',
      message: h('p', null, [
        h('p', { style: 'color: #303133' }, '对话中,不允许切换'),
      ]),
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      showCancelButton: false,
      dangerouslyUseHTMLString: true,
      customClass: 'commonElMessageBox',
      icon: markRaw(InfoFilled),
      center: true,
    })
    return false
  }
  if(item.id == activeBtnId.value) return;

  let reqData = {
    id: activeConversationId.value,
    maxContexts: 20, // 上下文数量
    maxTokens: 4096, // 回复数 Token 数
    modelId: item.id, // 模型
    systemMessage: null,
    temperature: 1 // 温度参数
  }

  // 切换模型
  submitForm(reqData, item);
}

/** 切换模型 */
const submitForm = async (reqData, item) => {
  isLoading.value = true
  try {
    await ChatConversationApi.updateChatConversationMy(reqData)
    message.success('对话配置已更新')
    // 对话更新成功,刷新最新信息
    await getConversation(activeConversationId.value)
    btnList.forEach(element => {
      element.isChecked = false;
    });

    item.isChecked = true;
    activeBtnId.value = item.id;
  } finally {
    isLoading.value = false
  }
}

/** 获取对话信息 */
const getConversation = async (id: number | null) => {
  if (!id) {
    return
  }
  const conversation: ChatConversationVO = await ChatConversationApi.getChatConversationMy(id)
  if (!conversation) {
    return
  }
  activeConversation.value = conversation
  activeConversationId.value = conversation.id
}

/**
 * 点击某个对话
 *
 * @param conversation 选中的对话
 * @return 是否切换成功
 */
const handleConversationClick = async (conversation: ChatConversationVO) => {
  // 对话进行中,不允许切换
  if (conversationInProgress.value) {
    // message.alert('对话中,不允许切换!')
    await ElMessageBox({
      title: '提示',
      message: h('p', null, [
        h('p', { style: 'color: #303133' }, '对话中,不允许切换'),
      ]),
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      showCancelButton: false,
      dangerouslyUseHTMLString: true,
      customClass: 'commonElMessageBox',
      icon: markRaw(InfoFilled),
      center: true,
    })
    return false
  }

  // 更新选中的对话 id
  activeConversationId.value = conversation.id
  activeConversation.value = conversation
  // 刷新 message 列表
  await getMessageList()
  // 滚动底部
  scrollToBottom(true)

  // 从首页进入到聊天页面自动发送
  if(isAutomatic.value) {
    handleSendByButton();
    isAutomatic.value = false;
  }
  // 清空输入框
  prompt.value = ''
  return true
}

/** 删除某个对话*/
const handlerConversationDelete = async (delConversation: ChatConversationVO) => {
  // 删除的对话如果是当前选中的,那么就重置
  if (activeConversationId.value === delConversation.id) {
    await handleConversationClear()
  }
}
/** 清空选中的对话 */
const handleConversationClear = async () => {
  // 对话进行中,不允许切换
  if (conversationInProgress.value) {
    // message.alert('对话中,不允许切换!')
    await ElMessageBox({
      title: '提示',
      message: h('p', null, [
        h('p', { style: 'color: #303133' }, '对话中,不允许切换'),
      ]),
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      showCancelButton: false,
      dangerouslyUseHTMLString: true,
      customClass: 'commonElMessageBox',
      icon: markRaw(InfoFilled),
      center: true,
    })
    return false
  }
  activeConversationId.value = null
  activeConversation.value = null
  activeMessageList.value = []
}

/** 修改聊天对话 */
const conversationUpdateFormRef = ref()
const openChatConversationUpdateForm = async () => {
  conversationUpdateFormRef.value.open(activeConversationId.value)
}
const handleConversationUpdateSuccess = async () => {
  
  // 对话更新成功,刷新最新信息
  await getConversation(activeConversationId.value)
}

/** 处理聊天对话的创建成功 */
const handleConversationCreate = async () => {
  // 创建对话
  await conversationListRef.value.createConversation()
}
/** 处理聊天对话的创建成功 */
const handleConversationCreateSuccess = async () => {
  // 创建新的对话,清空输入框
  prompt.value = ''
}

// =========== 【消息列表】相关 ===========

/** 获取消息 message 列表 */
const getMessageList = async () => {
  try {
    if (activeConversationId.value === null) {
      return
    }
    // Timer 定时器,如果加载速度很快,就不进入加载中
    activeMessageListLoadingTimer.value = setTimeout(() => {
      activeMessageListLoading.value = true
    }, 60)

    // 获取消息列表
    activeMessageList.value = await ChatMessageApi.getChatMessageListByConversationId(
      activeConversationId.value
    )

    // 滚动到最下面
    await nextTick()
    await scrollToBottom()
  } finally {
    // time 定时器,如果加载速度很快,就不进入加载中
    if (activeMessageListLoadingTimer.value) {
      clearTimeout(activeMessageListLoadingTimer.value)
    }
    // 加载结束
    activeMessageListLoading.value = false
  }
}

/**
 * 消息列表
 *
 * 和 {@link #getMessageList()} 的差异是,把 systemMessage 考虑进去
 */
const messageList = computed(() => {
  if (activeMessageList.value.length > 0) {
    return activeMessageList.value
  }
  // 没有消息时,如果有 systemMessage 则展示它
  if (activeConversation.value?.systemMessage) {
    return [
      {
        id: 0,
        type: 'system',
        content: activeConversation.value.systemMessage
      }
    ]
  }
  return []
})

/** 处理删除 message 消息 */
const handleMessageDelete = async (e: any) => {
  if (conversationInProgress.value) {
    await ElMessageBox({
      title: '提示',
      message: h('p', null, [
        h('p', { style: 'color: #303133' }, '回答中,不能删除'),
      ]),
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      showCancelButton: false,
      dangerouslyUseHTMLString: true,
      customClass: 'commonElMessageBox',
      icon: markRaw(InfoFilled),
      center: true,
    })
    return
  }

  await ElMessageBox({
    title: '提示',
    message: h('p', null, [
      h('p', { style: 'color: #303133' }, '确认删除吗?'),
    ]),
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    showCancelButton: true,
    dangerouslyUseHTMLString: true,
    customClass: 'commonElMessageBox',
    icon: markRaw(InfoFilled),
    center: true,
  })

  await ChatMessageApi.deleteChatMessage(e.id)
  message.success('删除成功!')
  // 刷新 message 列表
  // getMessageList()
  activeMessageList.value.forEach((element, index) => {
    if(element.id == e.id) {
      activeMessageList.value.splice(index, 1);
    }
  });
}

/** 处理 message 清空 */
const handlerMessageClear = async () => {
  if (!activeConversationId.value) {
    return
  }
  try {
    // 确认提示
    await message.delConfirm('确认清空对话消息?')
    // 清空对话
    await ChatMessageApi.deleteByConversationId(activeConversationId.value)
    // 刷新 message 列表
    activeMessageList.value = []
  } catch {}
}

/** 回到 message 列表的顶部 */
const handleGoTopMessage = () => {
  messageRef.value.handlerGoTop()
}

// =========== 【发送消息】相关 ===========

/** 处理来自 keydown 的发送消息 */
const handleSendByKeydown = async (event) => {
  // 判断用户是否在输入
  // if (isComposing.value) {
  //   event.preventDefault() // 防止默认的提交行为
  //   return
  // }
  // 进行中不允许发送
  if (conversationInProgress.value) {
    message.error('正在生成内容,请稍后!')
    event.preventDefault() // 防止默认的提交行为
    return false;
  }
  const content = prompt.value?.trim() as string

  if(!content) {
    message.error('请输入内容!')
    return false;
  }

  if (event.key === 'Enter') {
    if (event.shiftKey) {
      // 插入换行
      prompt.value += '\r\n'
      event.preventDefault() // 防止默认的换行行为
    } else {
      // 发送消息
      let fileUrl = JSON.parse(JSON.stringify(fileUrls.value));

      await doSendMessage({
        content: content,
        filePath: fileUrl && fileUrl.length > 0 ? fileUrl[0].url : '',
        fileName: fileUrl && fileUrl.length > 0 ? fileUrl[0].name : '',
      })
      event.preventDefault() // 防止默认的提交行为
    }
  }
}

/** 打开弹窗 */
const isAutomatic = ref(false); // 从首页进入到聊天页面自动发送
const open = async (obj: any) => {
  prompt.value = obj.prompt;
  fileUrls.value = obj.fileUrls;
  isAutomatic.value = true;
  // handleSendByButton();
}

/** 跳转至历史对话 */
const hisClick = async (item: any) => {
  nextTick(()=>{
    activeConversationId.value = item.id;
    handleConversationClick(item);
  });
}

defineExpose({ open, hisClick })

/** 处理来自【发送】按钮的发送消息 */
const handleSendByButton = () => {
  let fileUrl = JSON.parse(JSON.stringify(fileUrls.value));

  doSendMessage({
    content: prompt.value?.trim() as string,
    filePath: fileUrl && fileUrl.length > 0 ? fileUrl[0].url : '',
    fileName: fileUrl && fileUrl.length > 0 ? fileUrl[0].name : '',
  })
}

/** 处理 prompt 输入变化 */
const handlePromptInput = (event) => {
  // 非输入法 输入设置为 true
  if (!isComposing.value) {
    // 回车 event data 是 null
    if (event.data == null) {
      return
    }
    isComposing.value = true
  }
  // 清理定时器
  if (inputTimeout.value) {
    clearTimeout(inputTimeout.value)
  }
  // 重置定时器
  inputTimeout.value = setTimeout(() => {
    isComposing.value = false
  }, 400)
}
// TODO @芋艿:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑
const onCompositionstart = () => {
  isComposing.value = true
}
const onCompositionend = () => {
  // console.log('输入结束...')
  setTimeout(() => {
    isComposing.value = false
  }, 200)
}

/** 真正执行【发送】消息操作 */
const doSendMessage = async (msg: any) => {
  // 校验
  if (msg.content.length < 1) {
    message.error('请输入内容!')
    return false;
  }
  if (activeConversationId.value == null) {
    message.error('还没创建对话,不能发送!')
    return false;
  }

  // 清空输入框
  prompt.value = ''
  fileUrls.value = [];
  // 执行发送
  await doSendMessageStream({
    conversationId: activeConversationId.value,
    content: msg.content,
    filePath: msg.filePath,
    fileName: msg.fileName,
  } as ChatMessageVO)
}

/** 真正执行【发送】消息操作 */
const doSendMessageStream = async (userMessage: ChatMessageVO) => {
  // 创建 AbortController 实例,以便中止请求
  conversationInAbortController.value = new AbortController()
  // 标记对话进行中
  conversationInProgress.value = true
  // 设置为空
  receiveMessageFullText.value = ''

  try {
    // 1.1 先添加两个假数据,等 stream 返回再替换
    activeMessageList.value.push({
      id: -1,
      conversationId: activeConversationId.value,
      type: 'user',
      content: userMessage.content,
      createTime: new Date(),
      filePath: userMessage.filePath,
      fileName: userMessage.fileName
    } as ChatMessageVO)
    activeMessageList.value.push({
      id: -2,
      conversationId: activeConversationId.value,
      type: 'assistant',
      content: '思考中...',
      createTime: new Date()
    } as ChatMessageVO)

    // 修改对话名称
    conversationListRef.value.updateConversation(userMessage.content)

    // 1.2 滚动到最下面
    await nextTick()
    await scrollToBottom() // 底部
    // 1.3 开始滚动
    textRoll()

    // 2. 发送 event stream
    let isFirstChunk = true // 是否是第一个 chunk 消息段
    await ChatMessageApi.sendChatMessageStream(
      userMessage.conversationId,
      userMessage.content,
      userMessage.filePath,
      userMessage.fileName,
      conversationInAbortController.value,
      enableContext.value,
      async (res) => {
        const { code, data, msg } = JSON.parse(res.data)
        if (code !== 0) {
          // message.alert(`对话异常! ${msg}`)
          await ElMessageBox({
            title: '提示',
            message: h('p', null, [
              h('p', { style: 'color: #303133' }, `对话异常! ${msg}`),
            ]),
            confirmButtonText: '确定',
            cancelButtonText: '取消',
            showCancelButton: false,
            dangerouslyUseHTMLString: true,
            customClass: 'commonElMessageBox',
            icon: markRaw(InfoFilled),
            center: true,
          })
          return
        }

        // 如果内容为空,就不处理。
        if (data.receive.content === '') {
          return
        }
        // 首次返回需要添加一个 message 到页面,后面的都是更新
        if (isFirstChunk) {
          isFirstChunk = false
          // 弹出两个假数据
          activeMessageList.value.pop()
          activeMessageList.value.pop()
          // 更新返回的数据
          activeMessageList.value.push(data.send)
          activeMessageList.value.push(data.receive)
        }
        // debugger
        receiveMessageFullText.value = receiveMessageFullText.value + data.receive.content
        // 滚动到最下面
        await scrollToBottom()
      },
      (error) => {
        // message.alert(`对话异常! ${error}`)
        ElMessageBox({
          title: '提示',
          message: h('p', null, [
            h('p', { style: 'color: #303133' }, `对话异常! ${error}`),
          ]),
          confirmButtonText: '确定',
          cancelButtonText: '取消',
          showCancelButton: false,
          dangerouslyUseHTMLString: true,
          customClass: 'commonElMessageBox',
          icon: markRaw(InfoFilled),
          center: true,
        })
        stopStream()
        throw new Error(error);
      },
      () => {
        stopStream()
      }
    )
  } catch {}
}

/** 停止 stream 流式调用 */
const stopStream = async () => {
  // tip:如果 stream 进行中的 message,就需要调用 controller 结束
  if (conversationInAbortController.value) {
    conversationInAbortController.value.abort()
  }
  // 设置为 false
  conversationInProgress.value = false

  // 获取消息列表
  activeMessageList.value = await ChatMessageApi.getChatMessageListByConversationId(
    activeConversationId.value
  )

  activeMessageList.value = await ChatMessageApi.getChatMessageListByConversationId(
    activeConversationId.value
  )
}

/** 编辑 message:设置为 prompt,可以再次编辑 */
const handleMessageEdit = (message: ChatMessageVO) => {
  prompt.value = message.content
}

/** 刷新 message:基于指定消息,再次发起对话 */
const handleMessageRefresh = async (messageobj: ChatMessageVO) => {
  if (conversationInProgress.value) {
    message.error('正在生成内容,请稍后!')
    return false;
  }

  doSendMessage({
    content: messageobj.content,
    filePath: messageobj.filePath,
    fileName: messageobj.fileName,
  })
  // 滚动到最下面
  await nextTick()
  await scrollToBottom(true)
}
/**
 * 处理“继续生成”被中断的消息
 */
 const handleResume = async (message: ChatMessageVO) => {
  conversationInAbortController.value = new AbortController()
  conversationInProgress.value = true;
  try {
    await ChatMessageApi.resumeChatMessageStream(
      message.conversationId,  
      message.id,              
      message.content, 
      conversationInAbortController.value,         
      (event) => {          
        if (event.data === '[DONE]') {
          message.interrupted = false
          return
        }
        try {
          const result = JSON.parse(event.data)
          if (result.code === 0 && result.data?.receive?.content) {
            const newContent = result.data.receive.content
            message.content += newContent // 追加新内容
          }
        } catch (e) {
          console.error('解析流数据失败:', e)
        }
      },
      (error) => {
        // message.alert(`对话异常! ${error}`)
        ElMessageBox({
          title: '提示',
          message: h('p', null, [
            h('p', { style: 'color: #303133' }, `对话异常! ${error}`),
          ]),
          confirmButtonText: '确定',
          cancelButtonText: '取消',
          showCancelButton: false,
          dangerouslyUseHTMLString: true,
          customClass: 'commonElMessageBox',
          icon: markRaw(InfoFilled),
          center: true,
        })
        stopStream()
        throw new Error(error);
      },
      () => {
        stopStream()
      }
    )
  } catch (err) {
    console.error('调用 resume API 失败:', err)
    ElMessage.error('网络错误')
  }
}
// ============== 【消息滚动】相关 =============

/** 滚动到 message 底部 */
const scrollToBottom = async (isIgnore?: boolean) => {
  await nextTick()
  if (messageRef.value) {
    messageRef.value.scrollToBottom(isIgnore)
  }
}

/** 自提滚动效果 */
const textRoll = async () => {
  let index = 0
  try {
    // 只能执行一次
    if (textRoleRunning.value) {
      return
    }
    // 设置状态
    textRoleRunning.value = true
    receiveMessageDisplayedText.value = ''
    const task = async () => {
      // 调整速度
      const diff =
        (receiveMessageFullText.value.length - receiveMessageDisplayedText.value.length) / 10
      if (diff > 5) {
        textSpeed.value = 10
      } else if (diff > 2) {
        textSpeed.value = 30
      } else if (diff > 1.5) {
        textSpeed.value = 50
      } else {
        textSpeed.value = 100
      }
      // 对话结束,就按 30 的速度
      if (!conversationInProgress.value) {
        textSpeed.value = 10
      }

      if (index < receiveMessageFullText.value.length) {
        receiveMessageDisplayedText.value += receiveMessageFullText.value[index]
        index++

        // 更新 message
        const lastMessage = activeMessageList.value[activeMessageList.value.length - 1]
        lastMessage.content = receiveMessageDisplayedText.value
        // 滚动到住下面
        await scrollToBottom()
        // 重新设置任务
        timer = setTimeout(task, textSpeed.value)
      } else {
        // 不是对话中可以结束
        if (!conversationInProgress.value) {
          textRoleRunning.value = false
          clearTimeout(timer)
        } else {
          // 重新设置任务
          timer = setTimeout(task, textSpeed.value)
        }
      }
    }
    let timer = setTimeout(task, textSpeed.value)
  } catch {}
}

/** 删除文件 */
const delFile = () => {
  fileUrls.value = [];
}


getQuickOptions();

/** 初始化 **/
onMounted(async () => {
  // 如果有 conversationId 参数,则默认选中
  if (route.query.conversationId) {
    const id = route.query.conversationId as unknown as number
    activeConversationId.value = id
    await getConversation(id)
  }

  // 获取列表数据
  activeMessageListLoading.value = true
  await getMessageList()
})
</script>

<style lang="scss" scoped>
.ai-layout {
  // position: absolute;
  // flex: 1;
  // top: 0;
  // left: 0;
  height: 100%;
  width: 100%;
}

.conversation-container {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  padding: 10px 10px 0;

  .btn-new-conversation {
    padding: 18px 0;
    color: #FFFFFF;
  }

  .search-input {
    margin-top: 20px;
  }

  .conversation-list {
    margin-top: 20px;

    .conversation {
      display: flex;
      flex-direction: row;
      justify-content: space-between;
      flex: 1;
      padding: 0 5px;
      margin-top: 10px;
      cursor: pointer;
      border-radius: 5px;
      align-items: center;
      line-height: 30px;

      &.active {
        background-color: #e6e6e6;

        .button {
          display: inline-block;
        }
      }

      .title-wrapper {
        display: flex;
        flex-direction: row;
        align-items: center;
      }

      .title {
        padding: 5px 10px;
        max-width: 220px;
        font-size: 14px;
        overflow: hidden;
        white-space: nowrap;
        text-overflow: ellipsis;
      }

      .avatar {
        width: 28px;
        height: 28px;
        display: flex;
        flex-direction: row;
        justify-items: center;
      }

      // 对话编辑、删除
      .button-wrapper {
        right: 2px;
        display: flex;
        flex-direction: row;
        justify-items: center;
        color: #606266;

        .el-icon {
          margin-right: 5px;
        }
      }
    }
  }

  // 角色仓库、清空未设置对话
  .tool-box {
    line-height: 35px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    color: var(--el-text-color);

    > div {
      display: flex;
      align-items: center;
      color: #606266;
      padding: 0;
      margin: 0;
      cursor: pointer;

      > span {
        margin-left: 5px;
      }
    }
  }
}

// 头部
.detail-container {
  // background: #ffffff;

  .header {
    display: flex;
    // flex-direction: row;
    align-items: center;
    justify-content: right;
    background: #FFFFFF;
    // box-shadow: 0 0 0 0 #dcdfe6;
    border-bottom: 1px solid #dcdfe6;
    border-radius: 7px 7px 7px 7px;
    margin-bottom: 10px;

    .title {
      font-size: 18px;
      font-weight: bold;
    }

    .btns {
      display: flex;
      width: 300px;
      flex-direction: row;
      justify-content: flex-end;
      //justify-content: space-between;

      .btn {
        padding: 10px;
      }
    }
  }
}

// main 容器
.main-container {
  margin: 0;
  padding: 0;
  position: relative;
  height: 100%;
  width: 100%;
  border-radius: 7px 7px 7px 7px;

  .message-container {
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    right: 0;
    overflow-y: hidden;
    padding: 0;
    margin: 0;
    padding: 3px;
  }
}

// 底部
.footer-container {
  display: flex;
  flex-direction: column;
  height: auto;
  margin: 0;
  padding: 0;
  margin-top: 12px;

  .quick-instructions {
    width: 100%;
    padding: 6px 27px;
    border-bottom: 1px solid #D8D8D8;
    box-sizing: border-box;
    .left-box {
      cursor: pointer;
      height: 30px;
      padding: 0 10px;
      margin: 6px 0 6px 0;
      line-height: 30px;
    }
    .quick-box {
      max-height: 96px;
      ::v-deep .el-scrollbar__view {
        display: flex;
        flex-wrap: wrap;
        justify-content: flex-start;
      }
      .quick-list {
        height: 30px;
        padding: 0 10px;
        margin: 6px 18px 6px 0;
        line-height: 30px;
        border-radius: 8px 8px 8px 8px;
        border: 1px solid #175DEC;
        box-sizing: border-box;
        font-weight: 400;
        font-size: 14px;
        color: #175DEC;
        cursor: pointer;
        .el-icon {
          margin-left: 5px;
          position: relative;
          top: 2px;
        }
      }
      .quick-list:hover {
        background: #175DEC;
        color: #FFFFFF;
        .el-icon {
          color: #FFFFFF;
          transform: rotate(180deg);
          transition: .3s;
        }
      }
    }
  }

  .prompt-from {
    display: flex;
    flex-direction: column;
    height: auto;
    border-radius: 11px 11px 11px 11px;
    border: 1px solid #D8D8D8;
    margin: 12px 32px 7px 23px;
    padding: 11px 11px 4 13px;
    ::v-deep {
      .el-textarea__inner {
        padding: 0;
        box-shadow: none;
        resize:none;
        border:0;
      }

      /* 隐藏滚动条 */
      .el-textarea__inner::-webkit-scrollbar {
        display: none; /* Chrome, Safari, Opera*/
      }
      
      .el-textarea__inner {
        -ms-overflow-style: none;  /* IE and Edge */
        scrollbar-width: none;  /* Firefox */
      }
    }
  }

  .prompt-input {
    // height: 50px;
    //box-shadow: none;
    border: none;
    box-sizing: border-box;
    resize: none;
    padding: 5px;
    overflow: auto;
    ::v-deep {
      textarea {
        font-size: 18px;
        letter-spacing: 2px;
        color: #595959;
      }
      textarea::placeholder {
        font-weight: 400;
        font-size: 18px;
        color: #BBBBBB;
      }
    }
  }

  .prompt-input:focus {
    outline: none;
  }

  .prompt-btns {
    // display: flex;
    // justify-content: space-between;
    padding: 5px;
    .deep-btn {
      height: 28px;
      padding: 0 12px;
      background: #FFFFFF;
      border-radius: 46px 46px 46px 46px;
      border: 1px solid #999999;
      margin-right: 8px;
      cursor: pointer;
      .deep-img {
        height: 17px;
        width: 17px;
      }
      .deep-text {
        font-weight: 400;
        font-size: 12px;
        color: #999999;
        line-height: 17px;
        margin-left: 6px;
      }
    }
    .deep-btn.active {
      background: #175DEC;
      border: 1px solid #175DEC;
      .deep-text {
        color: #FFFFFF;
      }
    }
    .upload-img {
      height: 26px;
      width: 26px;
      margin-right: 17px;
      cursor: pointer;
    }

    ::v-deep .upload-file {
      margin-right: 20px;
      margin-top: 4px;
    }
    .file-name-text {
      font-weight: 400;
      font-size: 14px;
      color: #666;
    }
    ::v-deep {
      .del-file {
        margin: 0 10px;
        cursor: pointer;
      }
      .del-file:hover {
        color: #3D3D3D;
      }
    }
    .send-btn {
      width: 55px;
      height: 38px;
      border-radius: 46px 46px 46px 46px;
      .message-img {
        height: 28px;
        width: 26px;
      }
    }
    .send-cancle-btn {
      width: 90px;
      height: 28px;
      border-radius: 46px 46px 46px 46px;
      font-weight: 400;
      font-size: 16px;
      color: #FFFFFF;
    }

    /* From Uiverse.io by MikeeMikee */ 
    .button {
      cursor: pointer;
      border: none;
      // background: trans;
      color: #fff;
      width: 28px;
      height: 28px;
      border-radius: 50%;
      overflow: hidden;
      position: relative;
      display: grid;
      place-content: center;
      transition:
        background 300ms,
        transform 200ms;
      font-weight: 600;
    }

    .button__text {
      position: absolute;
      inset: 0;
      animation: text-rotation 8s linear infinite;

      > span {
        position: absolute;
        transform: rotate(calc(20deg * var(--index)));
        inset: 7px;
      }
    }

    .button__circle {
      position: relative;
      width: 28px;
      height: 28px;
      overflow: hidden;
      background: #E6EFF7;
      color: #175DEC;
      border-radius: 50%;
      display: flex;
      align-items: center;
      justify-content: center;
      svg {
        width: 18px;
        height: 16px;
      }
    }

    .button__icon--copy {
      position: absolute;
      transform: translate(-150%, 150%);
    }

    .button:hover {
      background: transparent;
      transform: scale(1.05);
    }

    .button:hover .button__icon {

    }

    .button:hover .button__icon:first-child {
      transition: transform 0.3s ease-in-out;
      transform: translate(150%, -150%);
    }

    .button:hover .button__icon--copy {
      transition: transform 0.3s ease-in-out 0.1s;
      transform: translate(0);
    }

    @keyframes text-rotation {
      to {
        rotate: 360deg;
      }
    }

    .button:active {
      transform: scale(0.95);
    }
  }
}
</style>
<style lang="scss">
.quick-popover {
  width: auto !important;
  max-width: 720px !important;
  background: transparent !important;
  box-shadow: none !important;
  border: none !important;
  padding: 0 !important;
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-start;
  .quick-children-list {
    height: 25px;
    padding: 0 8px;
    margin: 5px 2px 0 0;
    line-height: 25px;
    border-radius: 4px 4px 4px 4px;
    border: 1px solid #175DEC;
    box-sizing: border-box;
    font-weight: 400;
    font-size: 14px;
    color: #175DEC;
    background: #FFFFFF;
    cursor: pointer;
  }
  .quick-children-list:hover {
    background: #E6EEF7 !important;
    color: #359EFF !important;
  }
  .el-popper__arrow {
    display: none;
  }
}
</style>

/src/views/Home/ai/chat/index/components/message/MessageList.vue变更

增加继续按钮
在这里插入图片描述

           <el-button v-debounce v-if="item.interrupted"  @click="onResume(item)"  class="btn-cus"  link  title="继续">
                <el-icon size="17" :color="'#175DEC'">
                  <VideoPlay  />
                </el-icon>
              </el-button>

继续按钮的样式
在这里插入图片描述

VideoPlay

继续的emits
在这里插入图片描述

onResume

统一格式定义继续方法
在这里插入图片描述

/** 继续 */
const onResume = async (message: ChatMessageVO) => {
  // handleGoBottom()
  message.interrupted = false;
  emits('onResume', message)
}

完整代码:

<template>
  <div ref="messageContainer" class="h-100% overflow-y-auto relative">
    <!-- <el-scrollbar height="100%"> -->
      <div class="chat-list" v-for="(item, index) in list" :key="index">
        <!-- 靠左 message:system、assistant 类型 -->
        <div class="left-message message-item" v-if="item.type !== 'user'">
          <div class="avatar">
            <el-avatar :src="roleAvatar" />
          </div>
          <div class="message">
            <div>
              <el-text class="time">{{ formatDate(item.createTime) }}</el-text>
            </div>
            <div class="left-text-container" ref="markdownViewRef">
              <MarkdownView class="left-text" :content="item.content" />
            </div>
            <div class="left-btns">
              <el-button class="btn-cus" link @click="copyContent(item.content)" title="复制">
                <!-- <img class="btn-image" src="@/assets/ai/copy.svg" /> -->
                <el-icon size="17" :color="'#175DEC'"><DocumentCopy /></el-icon>
              </el-button>
              <el-button v-if="item.id > 0" class="btn-cus" link @click="onDelete(item.id)" title="删除">
                <!-- <img class="btn-image h-17px" src="@/assets/ai/delete.svg" /> -->
                <el-icon size="17" :color="'#175DEC'"><Delete /></el-icon>
              </el-button>
              <el-button v-debounce v-if="item.interrupted"  @click="onResume(item)"  class="btn-cus"  link  title="继续">
                <el-icon size="17" :color="'#175DEC'">
                  <VideoPlay  />
                </el-icon>
              </el-button>
            </div>
          </div>
        </div>
        <!-- 靠右 message:user 类型 -->
        <div class="right-message message-item" v-if="item.type === 'user'">
          <div class="avatar">
            <el-avatar :src="userAvatar" />
          </div>
          <div class="message">
            <div class="display-flex-right">
              <el-text class="time">{{ formatDate(item.createTime) }}</el-text>
            </div>
            <div class="right-text-container" v-if="item.fileName && item.filePath">
              <div class="file-box display-flex">
                <img v-if="item.filePath.split('.')[1]=='pdf'" src="@/assets/imgs/courseDesign/pdfPng.png" />
                <img v-else-if="item.filePath.split('.')[1]=='doc'" src="@/assets/imgs/courseDesign/doc.png" />
                <img v-else src="@/assets/imgs/courseDesign/docPng.png" />
                <span class="ellipsis" style="margin-left: 13px;">{{item.fileName}}.{{item.filePath.split('.')[1]}}</span>
              </div>
            </div>
            <div class="right-text-container">
              <div class="right-text">{{ item.content }}</div>
            </div>
            <div class="right-btns">
              <el-button class="btn-cus margin-l-12" link @click="copyContent(item.content)" title="复制">
                <!-- <img class="btn-image" src="@/assets/ai/copy.svg" /> -->
                <el-icon size="17" :color="'#175DEC'"><DocumentCopy /></el-icon>
              </el-button>
              <el-button class="btn-cus" link @click="onDelete(item.id)" title="删除">
                <!-- <img class="btn-image h-17px mr-12px" src="@/assets/ai/delete.svg" /> -->
                <el-icon size="17" :color="'#175DEC'"><Delete /></el-icon>
              </el-button>
              <el-button class="btn-cus" link @click="onRefresh(item)" title="再次提问">
                <el-icon size="17" :color="'#175DEC'"><RefreshRight /></el-icon>
              </el-button>
              <!-- <el-button class="btn-cus" link @click="onEdit(item)">
                <el-icon size="17"><Edit /></el-icon>
              </el-button> -->
            </div>
          </div>
        </div>
      </div>
    <!-- </el-scrollbar> -->
  </div>
  <!-- 回到底部 -->
  <div v-if="isScrolling" class="to-bottom" @click="handleGoBottom">
    <el-button :icon="ArrowDownBold" circle />
  </div>
</template>
<script setup lang="ts">
import { PropType } from 'vue'
import { formatDate } from '@/utils/formatTime'
import MarkdownView from '@/components/MarkdownView/index.vue'
import { useClipboard } from '@vueuse/core'
import { ArrowDownBold, Edit, RefreshRight, DocumentCopy, Delete ,VideoPlay  } from '@element-plus/icons-vue'
import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
import { ChatConversationVO } from '@/api/ai/chat/conversation'
import { useUserStore } from '@/store/modules/user'
import userAvatarDefaultImg from '@/assets/imgs/home/25.png'
import roleAvatarDefaultImg from '@/assets/imgs/home/19.png'

const message = useMessage() // 消息弹窗
const { copy } = useClipboard() // 初始化 copy 到粘贴板
const userStore = useUserStore()

// 判断“消息列表”滚动的位置(用于判断是否需要滚动到消息最下方)
const messageContainer: any = ref(null)
const isScrolling = ref(false) //用于判断用户是否在滚动

const userAvatar = computed(() => userStore.user.avatar || userAvatarDefaultImg)
const roleAvatar = computed(() => props.conversation.roleAvatar ?? roleAvatarDefaultImg)

// 定义 props
const props = defineProps({
  conversation: {
    type: Object as PropType<ChatConversationVO>,
    required: true
  },
  list: {
    type: Array as PropType<ChatMessageVO[]>,
    required: true
  }
})

const { list } = toRefs(props) // 消息列表

const emits = defineEmits(['onDeleteSuccess', 'onRefresh', 'onEdit' ,'onResume']) // 定义 emits

// ============ 处理对话滚动 ==============

/** 滚动到底部 */
const scrollToBottom = async (isIgnore?: boolean) => {
  // 注意要使用 nextTick 以免获取不到 dom
  await nextTick()
  if (isIgnore || !isScrolling.value) {
    messageContainer.value.scrollTop =
      messageContainer.value.scrollHeight - messageContainer.value.offsetHeight
  }
}

function handleScroll() {
  const scrollContainer = messageContainer.value
  const scrollTop = scrollContainer.scrollTop
  const scrollHeight = scrollContainer.scrollHeight
  const offsetHeight = scrollContainer.offsetHeight
  if (scrollTop + offsetHeight < scrollHeight - 100) {
    // 用户开始滚动并在最底部之上,取消保持在最底部的效果
    isScrolling.value = true
  } else {
    // 用户停止滚动并滚动到最底部,开启保持到最底部的效果
    isScrolling.value = false
  }
}

/** 回到底部 */
const handleGoBottom = async () => {
  const scrollContainer = messageContainer.value
  scrollContainer.scrollTop = scrollContainer.scrollHeight
}

/** 回到顶部 */
const handlerGoTop = async () => {
  const scrollContainer = messageContainer.value
  scrollContainer.scrollTop = 0
}

defineExpose({ scrollToBottom, handlerGoTop }) // 提供方法给 parent 调用

// ============ 处理消息操作 ==============

/** 复制 */
const copyContent = async (content) => {
  // 先判断当前有没有clipboard实例,如果有,则使用useClipboard;如果没有,则使用js原生方法
  if (navigator.clipboard) {
    await copy(content)
  } else {
    const input = document.createElement('input');
    input.setAttribute('value', content);
    document.body.appendChild(input);
    input.select();
    document.execCommand('copy');
    document.body.removeChild(input);
  }
  message.success('复制成功!')
}

/** 删除 */
const onDelete = async (id) => {
  // 删除 message
  // await ChatMessageApi.deleteChatMessage(id)
  // message.success('删除成功!')
  // 回调
  emits('onDeleteSuccess', {
    id: id
  })
}

/** 刷新 */
const onRefresh = async (message: ChatMessageVO) => {
  handleGoBottom();
  emits('onRefresh', message)
}

/** 编辑 */
const onEdit = async (message: ChatMessageVO) => {
  emits('onEdit', message)
}
/** 继续 */
const onResume = async (message: ChatMessageVO) => {
  // handleGoBottom()
  message.interrupted = false;
  emits('onResume', message)
}
/** 初始化 */
onMounted(async () => {
  messageContainer.value.addEventListener('scroll', handleScroll)
})
</script>

<style scoped lang="scss">
.message-container {
  position: relative;
  overflow-y: scroll;
}

// 中间
.chat-list {
  display: flex;
  flex-direction: column;
  overflow-y: hidden;
  padding: 0 20px;
  .message-item {
    margin-top: 50px;
  }

  .left-message {
    display: flex;
    flex-direction: row;
    .el-avatar--circle {
      background-color: transparent;
    }
  }

  .right-message {
    display: flex;
    flex-direction: row-reverse;
    justify-content: flex-start;
    .el-avatar--circle {
      background-color: transparent;
    }
  }

  .message {
    display: flex;
    flex-direction: column;
    text-align: left;
    margin: 0 15px;
    width: 100%;
    overflow: hidden;

    .time {
      text-align: left;
      line-height: 30px;
    }

    .left-text-container {
      position: relative;
      display: flex;
      flex-direction: column;
      overflow-wrap: break-word;
      background: #E7F2FF;
      border-radius: 7px 7px 7px 7px;
      padding: 10px 10px 5px 10px;
      margin-right: 45px;

      .left-text {
        font-weight: 400;
        font-size: 16px;
        color: #3D3D3D;
      }
    }

    .file-box {
      max-width: 100%;
      font-weight: 400;
      font-size: 16px;
      color: #3D3D3D;
      background: #E7F2FF;
      border-radius: 7px 7px 7px 7px;
      border-radius: 10px;
      padding: 10px;
      margin-bottom: 15px;
      border: 1px solid #175DEC;
      img {
        width: 21px;
        height: 24px;
      }
    }

    .right-text-container {
      display: flex;
      flex-direction: row-reverse;
      margin-left: 45px;

      .right-text {
        font-size: 0.95rem;
        font-weight: 400;
        font-size: 16px;
        color: #3D3D3D;
        display: inline;
        background: #E7F2FF;
        border-radius: 7px 7px 7px 7px;
        border-radius: 10px;
        padding: 10px;
        width: auto;
        overflow-wrap: anywhere;
        white-space: pre-wrap;
      }
    }

    .left-btns {
      display: flex;
      flex-direction: row;
      margin-top: 8px;
    }

    .right-btns {
      display: flex;
      flex-direction: row-reverse;
      margin-top: 8px;
    }
  }

  // 复制、删除按钮
  .btn-cus {
    display: flex;
    background-color: transparent;
    align-items: center;

    .btn-image {
      height: 20px;
    }
  }

  .btn-cus:hover {
    cursor: pointer;
    background-color: #f6f6f6;
  }
}

// 回到底部
.to-bottom {
  position: absolute;
  z-index: 1000;
  bottom: 0;
  right: 50%;
}

</style>

/src/api/ai/chat/message/index.ts变更
聊天VO 增加是否被中断
在这里插入图片描述

  interrupted: boolean // 是否被中断

继续未完成的消息流 API调用


// 继续未完成的消息流
resumeChatMessageStream: async (
  conversationId: number,
  messageId:number,
  content: string,
  ctrl,
  onMessage,
  onError,
  onClose
) => {
  const token = getAccessToken()
  return fetchEventSource(`${config.base_url}/ai/chat/message/resume-stream`, {
      method: 'post',
      headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${token}`
      },
      openWhenHidden: true,
      body: JSON.stringify({
        conversationId,
        messageId,
        content
      }),
      onmessage: onMessage,
      onerror: onError,
      onclose: onClose,
      signal: ctrl.signal
  })
},

完整代码:

import request from '@/config/axios'
import { fetchEventSource } from '@microsoft/fetch-event-source'
import { getAccessToken } from '@/utils/auth'
import { config } from '@/config/axios/config'

// 聊天VO
export interface ChatMessageVO {
  id: number // 编号
  conversationId: number // 对话编号
  type: string // 消息类型
  userId: string // 用户编号
  roleId: string // 角色编号
  model: number // 模型标志
  modelId: number // 模型编号
  content: string // 聊天内容
  tokens: number // 消耗 Token 数量
  createTime: Date // 创建时间
  roleAvatar: string // 角色头像
  filePath: string // 文件url
  fileName: string // 文件名称
  interrupted: boolean // 是否被中断
}

// AI chat 聊天
export const ChatMessageApi = {
  // 消息列表
  getChatMessageListByConversationId: async (conversationId: number | null) => {
    return await request.get({
      url: `/ai/chat/message/list-by-conversation-id?conversationId=${conversationId}`
    })
  },

  // 发送 Stream 消息
  // 为什么不用 axios 呢?因为它不支持 SSE 调用
  sendChatMessageStream: async (
    conversationId: number,
    content: string,
    filePath: string,
    fileName: string,
    ctrl,
    enableContext: boolean,
    onMessage,
    onError,
    onClose
  ) => {
    const token = getAccessToken()
    return fetchEventSource(`${config.base_url}/ai/chat/message/send-stream`, {
      method: 'post',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`
      },
      openWhenHidden: true,
      body: JSON.stringify({
        conversationId,
        content,
        filePath,
        fileName,
        useContext: enableContext
      }),
      onmessage: onMessage,
      onerror: onError,
      onclose: onClose,
      signal: ctrl.signal
    })
  },

// 继续未完成的消息流
resumeChatMessageStream: async (
  conversationId: number,
  messageId:number,
  content: string,
  ctrl,
  onMessage,
  onError,
  onClose
) => {
  const token = getAccessToken()
  return fetchEventSource(`${config.base_url}/ai/chat/message/resume-stream`, {
      method: 'post',
      headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${token}`
      },
      openWhenHidden: true,
      body: JSON.stringify({
        conversationId,
        messageId,
        content
      }),
      onmessage: onMessage,
      onerror: onError,
      onclose: onClose,
      signal: ctrl.signal
  })
},

  // 删除消息
  deleteChatMessage: async (id: string) => {
    return await request.delete({ url: `/ai/chat/message/delete?id=${id}` })
  },

  // 删除指定对话的消息
  deleteByConversationId: async (conversationId: number) => {
    return await request.delete({
      url: `/ai/chat/message/delete-by-conversation-id?conversationId=${conversationId}`
    })
  },

  // 获得消息分页
  getChatMessagePage: async (params: any) => {
    return await request.get({ url: '/ai/chat/message/page', params })
  },

  // 管理员删除消息
  deleteChatMessageByAdmin: async (id: number) => {
    return await request.delete({ url: `/ai/chat/message/delete-by-admin?id=${id}` })
  }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值