前端对接豆包AI(vue3+TS版本)
前言
上篇文章《火山引擎接入豆包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 Markdownbreaks: 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-closealign-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 = truechatList.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 = falsenextTick(() => {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 = falsechatList.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
}