前言

上篇文章《火山引擎接入豆包AI(纯前端调用api的方式)》只写了js对接的方式,而且没有处理豆包返回的markdown格式的数据。这篇文章虽然是vue3的但是都是js逻辑是一样的

效果图
效果图

前期准备

下载处理markdown格式的插件依赖(我的是 “marked”: “^4.2.12”)

npm install marked

然后是封装函数markdown.ts

我这里只有最简单的,网上有很多插件可以加过滤还代码高亮的我这里并没有加,因为我们项目业务简单

import { marked } from "marked";
// import DOMPurify from "dompurify"; // 可选:安全净化

// 配置允许的HTML标签(根据需求调整)
const safeConfig = {
  ALLOWED_TAGS: [
    "h1",
    "h2",
    "h3",
    "h4",
    "strong",
    "em",
    "p",
    "br",
    "ul",
    "ol",
    "li",
  ],
  ALLOWED_ATTR: ["class", "style"],
};

/**
 * 解析 Markdown 文本为安全 HTML
 * @param content Markdown格式文本
 * @returns 安全HTML字符串
 */
export function parseMarkdown(content: string): string {
  // 创建自定义渲染器
  const renderer:any = new marked.Renderer();
  renderer.listitem = (text: string) => {
    return `<li class="cn-list-item">${text}</li>`;
  };

  // 启用marked的安全模式
  marked.setOptions({
    gfm: true, // 启用 GitHub Flavored Markdown
    breaks: true, // 禁用单换行转 <br>(保持原换行逻辑)
    pedantic: false, // 禁用严格模式(允许宽松的列表解析)
    silent: true, // 如果为 true,则解析器不会抛出任何异常或记录任何警告。任何错误都将作为字符串返回。
    renderer,
  });
  // 修复内容中的列表格式
  const fixedContent = content
    .replace(/^-\s+/gm, "- ") // 统一列表项格式
    .replace(/\n\s*-/g, "\n-"); // 修复多行列表

  return marked.parse(fixedContent) as string;

  // 解析Markdown并净化HTML
  // return DOMPurify.sanitize(marked.parse(content) as string, safeConfig);
}

然后是处理请求的请求封装,我封装在自己的API文件webBreed.ts里面

import { parseMarkdown } from "@/utils/markdown"; // 上面markdown.ts的解析器
// 使用模块级变量存储当前控制器
let currentController: AbortController | null = null;

/**
 * 中止AI分析请求的函数
 */
export const abortAIAnalysisRequest = () => {
  if (currentController) {
    currentController.abort();
    currentController = null;
    console.log("手动中止AI分析请求", currentController);
  }
};
// 对话API
export const SendAIAnalysisApi = async (
  data: {
    Animal?: string;
    Type?: string;
    Remark?: string;
    Photo?: string;
    Menu?: string;
  },
  callbacks: {
    onProgress: (text: string) => void;
    onComplete?: () => void;
    onError?: (error: Error) => void;
  }
) => {
  try {
    // 中止任何现有请求
    abortAIAnalysisRequest();

    // 创建新的请求控制器
    currentController = new AbortController();
    const { signal } = currentController;

    // 准备请求参数
    const problem = data.Remark || "";
    const token = localStorage.getItem("token") || "";

    // 动态确定API地址
    let httpUrl = "";
    if (
      location.href.includes("localhost") ||
      location.href.indexOf("192.168.1.") !== -1
    ) {
      httpUrl = "http://192.168.1.11:8081";
    } else {
      httpUrl = window.location.origin;
    }

    // 构建请求URL和参数
    const url = `${httpUrl}${process.env.VUE_APP_BASE_API}/api/WebBreed/SendAIAnalysis`;
    const params = {
      breeds: data.Animal || "",
      type: data.Type || "",
      problem,
      imgUrl: data.Photo || "",
      menu: data.Menu || "",
    };

    // 发起请求
    const response = await fetch(url.toString(), {
      headers: { token, "Content-Type": "application/json" },
      method: "POST",
      body: JSON.stringify(params),
      signal,
    });

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    // 处理流式响应
    const reader = response.body?.getReader();
    if (!reader) throw new Error("No readable stream received");

    const decoder = new TextDecoder();
    let fullText = "";

    while (true) {
      const { done, value } = await reader.read();
      if (done) {
        callbacks.onComplete?.();
        currentController = null;
        break;
      }

      const chunk = decoder.decode(value, { stream: true });
      fullText += chunk;

      // 解析并回调更新
      const parsedText = parseMarkdown(fullText);
      callbacks.onProgress(parsedText);
    }

    return fullText;
  } catch (error) {
    if (error instanceof Error && error.name !== "AbortError") {
      callbacks.onError?.(error);
    }
    currentController = null;
    throw error;
  }
};

封装的对话弹窗组件

<template>
    <div>
        <!-- AI正常对话的弹窗 -->
        <el-dialog class="AIDialoguePopup" v-model="dialogVisible" :title="dialogTitle" width="70vw" destroy-on-close
            align-center center :append-to-body="false" draggable @close="close">
            <div class="AIDialogCard" v-loading="fetchLoading" element-loading-text="深度思考中">
                <div class="AIDialoguePopupChatList">
                    <div class="item" :class="{ my: item.user === 1 }" v-for="(item, index) in chatList" :key="index">
                        <div class="user" v-if="item.user === 0">
                            <img :src="AiLogo" alt="AI">
                        </div>
                        <div class="chatDetails" v-html="item.content">
                        </div>
                        <!-- <div class="user" v-if="item.user === 1"></div> -->
                    </div>
                </div>
            </div>
            <div class="inputBox">
                <el-input class="textareaInput" v-model="form.Remark" :autosize="{ minRows: 3 }" maxlength="1000"
                    show-word-limit type="textarea" placeholder="请输入问题,小助将为您解答" @keyup.enter="toDiagnosis" />
                <div class="uploadOrBtn">
                    <!--  list-type="picture-card" -->
                    <el-upload ref="uploadRef" v-model:file-list="fileList" :action="action" :headers="headers"
                        :auto-upload="false" :limit="3" :multiple="true" accept="image/png,image/jpg,image/jpeg"
                        :on-exceed="handleExceed" :on-success="handleAvatarSuccess" :on-error="handleError"
                        :on-remove="handleRemove" :before-upload="beforeUpload">
                        <template #trigger>
                            <el-button type="primary" size="large">图片上传</el-button>
                        </template>
                    </el-upload>

                    <el-button type="success" size="large" @click="toDiagnosis">
                        提交
                    </el-button>
                </div>
            </div>
        </el-dialog>
    </div>
</template>

<script lang="ts" setup>
import { SendAIAnalysisApi, abortAIAnalysisRequest } from "@/api/webBreed"

import AiLogo from "/public/commonPage/Home/homeContent/AiLogo.png"
import {
    ref, reactive, watch, computed, Ref, nextTick,
    getCurrentInstance
} from "vue";

import { ElMessage } from "element-plus";
import type { UploadProps, UploadUserFile } from 'element-plus'
import Cookies from "js-cookie";


let emit = defineEmits(["changeShowAIDialoguePopup"]);
let props = defineProps<{
    show: boolean;
}>();

let dialogVisible = ref<boolean>(props.show || false);

watch(
    () => props.show,
    (newVal) => {
        dialogVisible.value = newVal;
    }
);

let dialogTitle = ref<string>("AI小助");
let fetchLoading = ref<boolean>(false);
let chatList = ref<any[]>([
    {
        content: '欢迎使用超能小助,请问有什么能帮助您的吗?',
        user: 0, // 0代表AI,1代表用户
    }
]);

let form = reactive({
    Remark: '',
    Photo: '',
})

const fileList = ref<any[]>([]);
const uploading = ref(false);

// 图片上传--上传的链接和请求头携带的token
const VUE_APP_BASE_API = process.env.VUE_APP_BASE_API;
let action = ref(
    VUE_APP_BASE_API + "/api/WebBreed/UploadFile?flowName=PastureMap"
);
// 上传参数请求头
let headers = reactive({
    token: Cookies.get("token") || "",
});
const uploadRef: Ref = ref(null);

// 上传前-限制文件大小和类型
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
    const isJPGorPNG = file.type === 'image/jpeg' || file.type === 'image/png';
    const isLt5M = file.size / 1024 / 1024 < 5;
    if (!isJPGorPNG) {
        ElMessage.error('只能上传JPG/PNG格式的图片!');
        return false;
    }
    if (!isLt5M) {
        ElMessage.error('图片大小不能超过5MB!');
        return false;
    }
    return true;
};

// 处理超出限制
const handleExceed: UploadProps['onExceed'] = (files) => {
    ElMessage.warning(`最多只能上传3张图片,当前选择了${files.length}`);
};

// 上传成功
const handleAvatarSuccess: UploadProps["onSuccess"] = (res, file) => {
    // console.log('上传成功', res, file, fileList.value);
    // 所有文件上传完成后,调用AI接口
    if (fileList.value.every(file => file.status === 'success')) {
        form.Photo = fileList.value.map((item: any) => item.response?.resultdata?.[0]).join(',')
        handleDetails();
        uploading.value = false;
    }
};

// 上传失败
const handleError = () => {
    uploading.value = false;
    ElMessage.warning("上传失败!");
};
// 删除
const handleRemove = (file: any) => {
    fileList.value = fileList.value.filter(item => item.uid !== file.uid);
};

// 新增滚动函数
const scrollToBottom = () => {
    const chatList = document.querySelector('.AIDialoguePopupChatList');
    if (chatList) {
        // chatList.scrollTop = chatList.scrollHeight; // 直接滚动到最底部
        // 或者用平滑滚动:
        chatList.scrollTo({
            top: chatList.scrollHeight,
            behavior: 'smooth'
        });
    }
};

const handleDetails = async () => {
    fetchLoading.value = true
    chatList.value.push({
        content: form.Remark,
        user: 1
    })
    let params = {
        Animal: '牛',
        Type: '自由问答',
        Menu: '',
        Remark: form.Remark || '',
        Photo: form.Photo || ''
    }
    form.Remark = ''
    form.Photo = ''
    fileList.value = []
    await SendAIAnalysisApi(params,
        {
            onProgress: (text) => {
                if (fetchLoading.value) {
                    fetchLoading.value = false
                }
                if (chatList.value[chatList.value.length - 1].user !== 0) {
                    chatList.value.push({
                        content: text,
                        user: 0
                    })
                } else {
                    chatList.value[chatList.value.length - 1].content = text
                }
                nextTick(() => {
                    scrollToBottom();
                });
            },
            onComplete: () => {
                fetchLoading.value = false
                nextTick(() => {
                    scrollToBottom();
                });
            },
            onError: (error) => {
                fetchLoading.value = false
            }
        })
}

const toDiagnosis = async () => {
    if (fetchLoading.value) {
        ElMessage.error('请先等小助回答完成!');
        return
    }
    abortAIAnalysisRequest()
    if (form.Remark === '') {
        ElMessage.error('请输入问题!');
        return
    }
    uploading.value = true;
    try {
        if (fileList.value.length > 0) {
            // 手动触发上传
            uploadRef.value!.submit();
        } else {
            // 如果没有图片,直接调用AI接口
            await handleDetails();
        }
    } catch (error) {
        uploading.value = false;
    }
}

const close = () => {
    abortAIAnalysisRequest()
    fetchLoading.value = false
    chatList.value = [
        {
            content: '欢迎使用超能小助,请问有什么能帮助您的吗?',
            user: 0
        }
    ]
    emit("changeShowAIDialoguePopup", false);
}
</script>

<style lang="less" scoped>
.AIDialogCard {
    height: 60vh;
    overflow-y: auto;

    .AIDialoguePopupChatList {
        height: 100%;
        overflow-y: auto;
        padding: 0 2px 0 0;

        &::-webkit-scrollbar {
            width: 5px;
            height: 5px;
            background: #002245;
        }

        &::-webkit-scrollbar-track,
        &-small::-webkit-scrollbar-track {
            border-radius: 10px;
            background: #002245;
        }

        &::-webkit-scrollbar-thumb,
        &-small::-webkit-scrollbar-thumb {
            border-radius: 5px;
            background-color: #409eff;
        }

        .item {
            display: flex;
            margin-bottom: 15px;

            .user {
                width: 40px;
                height: 40px;
                border: 1px solid #fff;
                border-radius: 50%;
                cursor: pointer;
                margin-right: 10px;

                img {
                    width: 100%;
                    height: 100%;
                }
            }

            :deep(.chatDetails) {
                flex: 1;
                // background-color: #fff;
                color: #000;
                background-color: rgba(255, 255, 255, .7);
                // color: #fff;
                padding: 10px 20px;
                border-radius: 20px;
                font-size: 16px;
                line-height: 1.5;
                // font-family: AlibabaPuHuiTi;

                h1 {
                    font-size: 32px;
                    font-weight: bold;
                    margin: 6px 0;
                }

                h2 {
                    font-size: 24px;
                    font-weight: bold;
                    margin: 6px 0;
                }

                h3 {
                    font-size: 20px;
                    font-weight: bold;
                    margin: 6px 0;
                }

                h4 {
                    font-size: 16px;
                    font-weight: bold;
                    margin: 6px 0;
                }

                h5 {
                    font-size: 13.28px;
                    font-weight: bold;
                    margin: 6px 0;
                }

                h6 {
                    font-size: 12px;
                    font-weight: bold;
                    margin: 6px 0;
                }

                /* 中文风格列表 */
                .cn-list-item {
                    list-style: none;
                    /* 隐藏默认符号 */
                    position: relative;
                    padding-left: 1.2em;
                    /* 留出符号空间 */
                    line-height: 1.5;

                    &::before {
                        content: "·";
                        /* 中文圆点符号 */
                        position: absolute;
                        left: 0;
                        font-weight: bold;
                        font-size: 1em;
                        color: #333;
                    }
                }

                /* 一级列表用 · */
                .cn-list-item::before {
                    content: "·";
                }

                /* 二级列表用 ▪ */
                ul ul .cn-list-item::before {
                    content: "▪";
                }

                /* 三级列表用 ▫ */
                ul ul ul .cn-list-item::before {
                    content: "▫";
                }

            }

            &.my {
                width: auto;
                justify-content: flex-end;

                .user {
                    width: 10px;
                }

                :deep(.chatDetails) {
                    flex: initial;
                    // background-color: #f5f5f5;
                    background-color: rgba(255, 255, 255, .8);
                    color: rgba(0, 0, 0, 0.85);
                }
            }
        }
    }

    .nullData {
        width: 100%;
        height: 100%;
        display: flex;
        justify-content: center;
        align-items: center;

        :deep(.el-empty) {
            .el-empty__description {
                p {
                    font-size: 20px;
                }
            }
        }
    }
}

.inputBox {
    margin-top: 10px;

    :deep(.textareaInput) {

        .el-textarea__inner,
        .el-input__count {
            background-color: rgba(255, 255, 255, .9);
        }
    }

    .uploadOrBtn {
        padding-top: 10px;
        display: flex;
        justify-content: space-between;
    }
}

:deep(.el-dialog.AIDialoguePopup) {
    // background-color: #f2f5f8;
    // background-image: url("/public/youRanImg/ColonyHouse/popup/popupBg.jpg");
    background-size: 100% 100%;

    .el-dialog__header {
        background-color: transparent;
        margin-right: 0;

        .el-dialog__title {
            color: #409eff;
            font-weight: bold;
            font-size: 30px;
        }
    }

    .el-dialog__body {
        padding: 15px 20px;
    }
}
</style>

使用组件

// show显示和changeShowAIDialoguePopup关闭回调
<AIDialoguePopup :show="AIDialoguePopupShow" @changeShowAIDialoguePopup="AIDialoguePopupShow = false" />

// 打开函数
let AIDialoguePopupShow = ref(false)
const dblClickAi = () => {
  // 显示对话聊天组件
  AIDialoguePopupShow.value = true
}
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐