最近在尝试接入讯飞星火的时候,本以为很顺利,结果出现了一堆bug,什么解析格式不对呀各种问题,我这里分享一下我的解决方案
想看完整代码的直接跳转即可,前面是一些组件化的说明
首先是链接讯飞星火的模型,首先是进入到讯飞星火官网、
进入到开发者平台,创建一个属于自己的模型,拿到app的id和秘钥
创建好之后,根据自己的http网址和密码访问api,代码如下,可直接拿来使用
async function callSparkStream(apiUrl, apiPassword, data) {
content.value = ""; // 清空输入框内容
const headers = {
Authorization: `Bearer ${apiPassword}`,
"Content-Type": "application/json",
};
try {
const response = await fetch(apiUrl, {
method: "POST",
headers,
body: JSON.stringify(data),
});
if (!response.body) {
console.error("No streaming data returned!");
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
// eslint-disable-next-line no-constant-condition
while (true) {
const { done, value } = await reader.read();
if (done) {
results.value.push(parsedStr.value); // 保存到历史记录
str.value = ""; // 清空当前内容
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.trim() === "") continue;
try {
const parsedData = JSON.parse(line.replace(/^data: /, "").trim());
console.log("parsedData:", parsedData);
if (parsedData.choices && parsedData.choices[0]?.delta?.content) {
const content = parsedData.choices[0].delta.content;
console.log("解析出来的内容:", content);
await renderText(content);
}
} catch (error) {
console.error("解析失败:", line);
}
}
}
} catch (error) {
console.error("请求失败:", error.message);
}
}
这个是调用大模型api,用的是流式接受
下面是调用这个方法的封装,拿来之后要先输入自己的网址和秘钥
const onSendRequest = (content) => {
// 下面这一行是跟我的html结合不用管
isShowdialog.value = true; // 显示对话框
const apiUrl = ""; // 换成自己的
const apiPassword = "";
const requestData = {
model: "4.0Ultra",
messages: [
{
role: "user",
content: content,
},
],
stream: true,
};
// 调用 AI 接口
callSparkStream(apiUrl, apiPassword, requestData);
};
然后你就会发现,返回来的格式是md,而且怎么给用户一种流式传输的感觉呢,那么就要用到我们下面的解析拿到的数据并逐字解析,这里面真的是一堆坑,赶时间的话直接copy,建议还是自己写一下
首先是先加入解析公式的MathJax
function loadMathJax() {
if (!window.MathJax) {
console.log("MathJax 加载中...");
const script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js";
script.async = true;
document.head.appendChild(script);
script.onload = () => {
console.log("MathJax 加载成功");
};
}
}
注意在挂载和更新的时候调用它
// #region 生命周期
onMounted(() => {
loadMathJax(); // 加载 MathJax
});
onUpdated(() => {
nextTick(() => {
if (window.MathJax) {
MathJax.typesetPromise(); // 更新时重新渲染公式
}
});
});
// #endregion
然后是逐个字符的解析并调用转换md的方法如下
// 逐个字符解析
function renderText(content) {
return new Promise((resolve) => {
let i = 0;
const interval = setInterval(() => {
console.log("当前字符:", content[i]);
str.value += content[i]; // 实时追加字符到显示内容
i++;
if (i >= content.length) {
clearInterval(interval);
resolve();
// 解析 Markdown 内容
parsedStr.value = parseMarkdown(str.value);
// parsedStr.value = str.value;
console.log("解析后的 Markdown 内容:", parsedStr.value);
// 实时检测 <p> 标签并渲染公式
nextTick(() => {
const paragraphs = document.querySelectorAll(".current p"); // 找到所有 <p> 标签
paragraphs.forEach((p) => {
MathJax.typesetPromise([p]).catch((err) => {
console.error("MathJax 渲染错误:", err.message);
});
});
});
}
}, 10); // 每 10ms 渲染一个字符
});
}
然后这个是将拿到的md转换为html格式,这里面点是真的多,用的是marked,这里面主要是防止md将表明公式的 \ 当成转义字符,通过正则表达式匹配,而且这里面返回的单引号和双引号可能不是很整齐,所以像我这样都转义一下就好了
function parseMarkdown(content) {
const formulaMap = [];
let formulaIndex = 0;
const normalizedContent = content.replace(/&#39;/g, "'");
const protectedContent = normalizedContent
.replace(
/\\\((.+?)\\\)|\\\[(.+?)\\\]|(\$\$[\s\S]+?\$\$)|\$(.+?)\$/g,
(match, ...groups) => {
if (groups[3]) {
const inlineFormula = groups[3].trim();
const protectedFormula = `\\(${inlineFormula}\\)`;
formulaMap.push(protectedFormula);
} else {
formulaMap.push(match);
}
return `@@FORMULA${formulaIndex++}@@`;
}
)
.replace(/\\\\/g, () => {
formulaMap.push("\\\\");
return `@@FORMULA${formulaIndex++}@@`;
})
.replace(/\\[\[\]]/g, (match) => {
formulaMap.push(match);
return `@@FORMULA${formulaIndex++}@@`;
});
// 在正式转换之前处理代码块中的内容
const preProcessedContent = protectedContent.replace(
/```([\s\S]*?)```/g,
(match, codeBlockContent) => {
const processedCodeBlock = codeBlockContent
// 只匹配单引号(跳过双引号)
.replace(/(?<!')'(?!')/g, "\\'") // 替换单独的单引号为 \'
.replace(/\\\\/g, "\\"); // 替换双反斜杠为单反斜杠
return `\`\`\`${processedCodeBlock}\`\`\``;
}
);
let htmlContent = marked(preProcessedContent);
// 防止 HTML 实体自动转换,统一替换全文内容中的 HTML 实体
htmlContent = htmlContent
.replace(/"/g, '"') // 替换所有 "
.replace(/'/g, "'") // 替换所有 '
.replace(/&/g, "&") // 替换所有 &
.replace(/\\'/g, "'") // 替换 \' 为 '
.replace(/\\'/g, "'"); // 替换所有 \' 为 '
htmlContent = htmlContent.replace(
/<pre><code class="language-(\w+)">([\s\S]*?)<\/code><\/pre>/g,
(match, language, code) => {
const highlightedCode = hljs.highlight(language, code, true).value;
return `
<div class="code-block-container">
<div class="code-block-header">
<span class="code-language">${language.toUpperCase()}</span>
<button class="copy-button" onclick="copyToClipboard(this)">Copy</button>
</div>
<pre class="code-block"><code class="language-${language}">${highlightedCode}</code></pre>
</div>
`;
}
);
htmlContent = htmlContent.replace(/@@FORMULA(\d+)@@/g, (match, index) => {
return formulaMap[index];
});
return htmlContent;
}
可以注意到我这里面有代码块还有copy按钮,这个copy功能代码如下,加进去即可
window.copyToClipboard = (button) => {
const codeBlock =
button.parentElement.nextElementSibling.querySelector("code");
if (codeBlock) {
const codeText = codeBlock.innerText;
navigator.clipboard.writeText(codeText).then(
() => {
button.textContent = "Copied!";
setTimeout(() => {
button.textContent = "Copy";
}, 2000);
},
(err) => {
console.error("Failed to copy text: ", err);
}
);
}
};
最后功能代码都完成了,剩下的就是样式了,那么直接看完整代码如下
这里用的vue3,template部分
<template>
<div>
<div class="options-container">
<input type="text" v-model="content" />
<button :disabled="!content.trim()" @click="onSendRequest(content)">
发送请求
</button>
</div>
<div class="result">
<div v-if="results.length >= 2 || isShowdialog">
<div
class="preview"
v-for="(result, index) in results.slice(0, -1)"
:key="index"
v-html="result"
></div>
</div>
<!-- 渲染解析后的 Markdown 内容 -->
<div
v-if="parsedStr.trim() != ''"
class="current"
v-html="parsedStr"
></div>
</div>
</div>
</template>
js部分
<script setup>
/* eslint-disable */
/* global MathJax */
import { onMounted, ref, nextTick, onUpdated } from "vue";
import { marked } from "marked";
import hljs from "highlight.js";
import "highlight.js/styles/github.css";
const isShowdialog = ref(false); // 控制对话框显示
const content = ref(""); // 用户输入的内容
const str = ref(""); // 动态显示的内容
const parsedStr = ref(""); // 解析后的 Markdown 内容
const results = ref([]); // 存储解析后的历史记录
// #region 加载 MathJax 的函数
function loadMathJax() {
if (!window.MathJax) {
console.log("MathJax 加载中...");
const script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js";
script.async = true;
document.head.appendChild(script);
script.onload = () => {
console.log("MathJax 加载成功");
};
}
}
// #endregion
// #region Markdown 转 HTML 并处理代码块的函数
function parseMarkdown(content) {
const formulaMap = [];
let formulaIndex = 0;
const normalizedContent = content.replace(/&#39;/g, "'");
const protectedContent = normalizedContent
.replace(
/\\\((.+?)\\\)|\\\[(.+?)\\\]|(\$\$[\s\S]+?\$\$)|\$(.+?)\$/g,
(match, ...groups) => {
if (groups[3]) {
const inlineFormula = groups[3].trim();
const protectedFormula = `\\(${inlineFormula}\\)`;
formulaMap.push(protectedFormula);
} else {
formulaMap.push(match);
}
return `@@FORMULA${formulaIndex++}@@`;
}
)
.replace(/\\\\/g, () => {
formulaMap.push("\\\\");
return `@@FORMULA${formulaIndex++}@@`;
})
.replace(/\\[\[\]]/g, (match) => {
formulaMap.push(match);
return `@@FORMULA${formulaIndex++}@@`;
});
// 在正式转换之前处理代码块中的内容
const preProcessedContent = protectedContent.replace(
/```([\s\S]*?)```/g,
(match, codeBlockContent) => {
const processedCodeBlock = codeBlockContent
// 只匹配单引号(跳过双引号)
.replace(/(?<!')'(?!')/g, "\\'") // 替换单独的单引号为 \'
.replace(/\\\\/g, "\\"); // 替换双反斜杠为单反斜杠
return `\`\`\`${processedCodeBlock}\`\`\``;
}
);
let htmlContent = marked(preProcessedContent);
// 防止 HTML 实体自动转换,统一替换全文内容中的 HTML 实体
htmlContent = htmlContent
.replace(/"/g, '"') // 替换所有 "
.replace(/'/g, "'") // 替换所有 '
.replace(/&/g, "&") // 替换所有 &
.replace(/\\'/g, "'") // 替换 \' 为 '
.replace(/\\'/g, "'"); // 替换所有 \' 为 '
htmlContent = htmlContent.replace(
/<pre><code class="language-(\w+)">([\s\S]*?)<\/code><\/pre>/g,
(match, language, code) => {
const highlightedCode = hljs.highlight(language, code, true).value;
return `
<div class="code-block-container">
<div class="code-block-header">
<span class="code-language">${language.toUpperCase()}</span>
<button class="copy-button" onclick="copyToClipboard(this)">Copy</button>
</div>
<pre class="code-block"><code class="language-${language}">${highlightedCode}</code></pre>
</div>
`;
}
);
htmlContent = htmlContent.replace(/@@FORMULA(\d+)@@/g, (match, index) => {
return formulaMap[index];
});
return htmlContent;
}
// #endregion
// #region 实时渲染内容并检测公式的函数
function renderText(content) {
return new Promise((resolve) => {
let i = 0;
const interval = setInterval(() => {
console.log("当前字符:", content[i]);
str.value += content[i]; // 实时追加字符到显示内容
i++;
if (i >= content.length) {
clearInterval(interval);
resolve();
// 解析 Markdown 内容
parsedStr.value = parseMarkdown(str.value);
// parsedStr.value = str.value;
console.log("解析后的 Markdown 内容:", parsedStr.value);
// 实时检测 <p> 标签并渲染公式
nextTick(() => {
const paragraphs = document.querySelectorAll(".current p"); // 找到所有 <p> 标签
paragraphs.forEach((p) => {
MathJax.typesetPromise([p]).catch((err) => {
console.error("MathJax 渲染错误:", err.message);
});
});
});
}
}, 10); // 每 10ms 渲染一个字符
});
}
// #endregion
renderText("$ax^2+by=c$");
//#region 调用 AI 接口主函数
async function callSparkStream(apiUrl, apiPassword, data) {
content.value = ""; // 清空输入框内容
const headers = {
Authorization: `Bearer ${apiPassword}`,
"Content-Type": "application/json",
};
try {
const response = await fetch(apiUrl, {
method: "POST",
headers,
body: JSON.stringify(data),
});
if (!response.body) {
console.error("No streaming data returned!");
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
// eslint-disable-next-line no-constant-condition
while (true) {
const { done, value } = await reader.read();
if (done) {
results.value.push(parsedStr.value); // 保存到历史记录
str.value = ""; // 清空当前内容
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.trim() === "") continue;
try {
const parsedData = JSON.parse(line.replace(/^data: /, "").trim());
console.log("parsedData:", parsedData);
if (parsedData.choices && parsedData.choices[0]?.delta?.content) {
const content = parsedData.choices[0].delta.content;
console.log("解析出来的内容:", content);
await renderText(content);
}
} catch (error) {
console.error("解析失败:", line);
}
}
}
} catch (error) {
console.error("请求失败:", error.message);
}
}
// #endregion
// #region 发送请求的函数
const onSendRequest = (content) => {
isShowdialog.value = true; // 显示对话框
const apiUrl = "";
const apiPassword = "";
const requestData = {
model: "4.0Ultra",
messages: [
{
role: "user",
content: content,
},
],
stream: true,
};
// 调用 AI 接口
callSparkStream(apiUrl, apiPassword, requestData);
};
// #endregion
// #region 复制代码块到剪贴板
window.copyToClipboard = (button) => {
const codeBlock =
button.parentElement.nextElementSibling.querySelector("code");
if (codeBlock) {
const codeText = codeBlock.innerText;
navigator.clipboard.writeText(codeText).then(
() => {
button.textContent = "Copied!";
setTimeout(() => {
button.textContent = "Copy";
}, 2000);
},
(err) => {
console.error("Failed to copy text: ", err);
}
);
}
};
// #endregion
// #region 生命周期
onMounted(() => {
loadMathJax(); // 加载 MathJax
});
onUpdated(() => {
nextTick(() => {
if (window.MathJax) {
MathJax.typesetPromise(); // 更新时重新渲染公式
}
});
});
// #endregion
</script>
css部分
<style lang="scss">
.result {
position: relative;
width: 500px;
}
.options-container {
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
label {
font-size: 16px;
color: #333;
}
input {
flex: 1;
padding: 5px;
font-size: 14px;
border-radius: 5px;
border: 1px solid #ddd;
}
button {
padding: 8px 15px;
font-size: 14px;
background-color: #4caf50;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s;
&:hover {
background-color: #45a049;
}
&:disabled {
background-color: #ccc;
cursor: not-allowed;
}
}
}
.preview {
padding: 10px;
margin: 20px;
border: 2px solid rebeccapurple;
}
.current {
padding: 10px;
margin: 20px;
border: 2px solid rebeccapurple;
}
.code-block-container {
position: relative;
background-color: #2d2d2d;
border-radius: 5px;
margin-bottom: 15px;
font-family: "Courier New", monospace;
overflow: hidden;
width: 100%;
/* 代码块标题栏 */
.code-block-header {
display: flex;
justify-content: space-between;
align-items: center;
background-color: rgb(81, 79, 79); /* 浅灰色背景 */
padding: 8px 12px;
border-bottom: 1px solid #ddd;
font-size: 14px;
color: white;
.code-language {
font-weight: bold;
color: #e4e2e2; /* 语言文字颜色 */
}
.copy-button {
background-color: #4caf50; /* 按钮绿色 */
color: white;
border: none;
border-radius: 3px;
padding: 5px 10px;
cursor: pointer;
font-size: 12px;
position: absolute; /* 固定到右上角 */
top: 5px;
right: 5px;
transition: background-color 0.3s;
&:hover {
background-color: #45a049;
}
}
}
/* 代码块样式 */
.code-block {
background-color: #2d2d2d; /* 黑色背景 */
color: white; /* 白色文字 */
border-radius: 0 0 5px 5px; /* 底部圆角 */
overflow-x: auto; /* 横向滚动条 */
padding: 15px;
white-space: pre; /* 禁止自动换行 */
}
}
</style>