前端对接豆包AI(vue3+TS版本)
本文介绍了如何在Vue3项目中接入豆包AI并处理Markdown格式的响应数据。主要步骤包括:1)安装marked插件处理Markdown格式;2)封装markdown解析函数支持基础HTML标签;3)实现请求封装,支持流式响应处理和手动中止请求;4)开发对话弹窗组件展示AI响应内容。文章提供了完整的代码实现,包括错误处理、本地开发环境判断等功能,适用于需要在前端展示AI生成内容的场景。
·
前言
上篇文章《火山引擎接入豆包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
}
更多推荐
所有评论(0)